更新ios
This commit is contained in:
@@ -788,12 +788,16 @@ const ConceptList = () => {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchHierarchy();
|
// 并行请求优化:三个请求相互独立,可以同时发起
|
||||||
await fetchPriceData();
|
const requests = [
|
||||||
// 如果当前是列表模式,加载概念列表
|
fetchHierarchy(),
|
||||||
|
fetchPriceData(),
|
||||||
|
];
|
||||||
|
// 如果当前是列表模式,同时加载概念列表
|
||||||
if (viewMode === 'list') {
|
if (viewMode === 'list') {
|
||||||
await searchConcepts(searchQuery, 1, false);
|
requests.push(searchConcepts(searchQuery, 1, false));
|
||||||
}
|
}
|
||||||
|
await Promise.all(requests);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ const EventDetail = ({ route, navigation }) => {
|
|||||||
onStockPress={handleStockPress}
|
onStockPress={handleStockPress}
|
||||||
showAll={showAllStocks}
|
showAll={showAllStocks}
|
||||||
onShowAll={() => setShowAllStocks(true)}
|
onShowAll={() => setShowAllStocks(true)}
|
||||||
|
eventTime={currentEvent?.event_time || currentEvent?.created_at}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 市场影响卡片 */}
|
{/* 市场影响卡片 */}
|
||||||
|
|||||||
279
MeAgent/src/screens/Events/MiniChart.js
Normal file
279
MeAgent/src/screens/Events/MiniChart.js
Normal file
@@ -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 (
|
||||||
|
<Box
|
||||||
|
w={CHART_WIDTH}
|
||||||
|
h={CHART_HEIGHT}
|
||||||
|
bg="rgba(30, 41, 59, 0.5)"
|
||||||
|
borderRadius={6}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Spinner size="sm" color="gray.600" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无数据状态
|
||||||
|
if (!chartData || chartData.pricePoints.length === 0) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w={CHART_WIDTH}
|
||||||
|
h={CHART_HEIGHT}
|
||||||
|
bg="rgba(30, 41, 59, 0.5)"
|
||||||
|
borderRadius={6}
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Text fontSize={9} color="gray.600">暂无数据</Text>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineColor = chartData.isUp ? '#EF4444' : '#22C55E';
|
||||||
|
const areaGradientId = chartData.isUp ? 'miniAreaUp' : 'miniAreaDown';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
w={CHART_WIDTH}
|
||||||
|
h={CHART_HEIGHT}
|
||||||
|
bg="rgba(30, 41, 59, 0.5)"
|
||||||
|
borderRadius={6}
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Svg width={CHART_WIDTH} height={CHART_HEIGHT}>
|
||||||
|
{/* 渐变定义 */}
|
||||||
|
<Defs>
|
||||||
|
<LinearGradient id="miniAreaUp" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<Stop offset="0%" stopColor="#EF4444" stopOpacity="0.25" />
|
||||||
|
<Stop offset="100%" stopColor="#EF4444" stopOpacity="0.02" />
|
||||||
|
</LinearGradient>
|
||||||
|
<LinearGradient id="miniAreaDown" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<Stop offset="0%" stopColor="#22C55E" stopOpacity="0.25" />
|
||||||
|
<Stop offset="100%" stopColor="#22C55E" stopOpacity="0.02" />
|
||||||
|
</LinearGradient>
|
||||||
|
</Defs>
|
||||||
|
|
||||||
|
{/* 昨收参考虚线 */}
|
||||||
|
<Line
|
||||||
|
x1={PADDING.left}
|
||||||
|
y1={chartData.preCloseY}
|
||||||
|
x2={CHART_WIDTH - PADDING.right}
|
||||||
|
y2={chartData.preCloseY}
|
||||||
|
stroke="rgba(255,255,255,0.15)"
|
||||||
|
strokeDasharray="2,2"
|
||||||
|
strokeWidth={0.5}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 分时线填充区域 */}
|
||||||
|
<Path
|
||||||
|
d={generateAreaPath(chartData.pricePoints, CHART_HEIGHT - PADDING.bottom)}
|
||||||
|
fill={`url(#${areaGradientId})`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 分时线 */}
|
||||||
|
<Path
|
||||||
|
d={generateLinePath(chartData.pricePoints)}
|
||||||
|
stroke={lineColor}
|
||||||
|
strokeWidth={1.2}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 事件时间金色竖线 */}
|
||||||
|
{chartData.eventLineX !== null && (
|
||||||
|
<>
|
||||||
|
{/* 竖线 */}
|
||||||
|
<Line
|
||||||
|
x1={chartData.eventLineX}
|
||||||
|
y1={PADDING.top}
|
||||||
|
x2={chartData.eventLineX}
|
||||||
|
y2={CHART_HEIGHT - PADDING.bottom}
|
||||||
|
stroke="#F59E0B"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
strokeOpacity={0.9}
|
||||||
|
/>
|
||||||
|
{/* 顶部小三角标记 */}
|
||||||
|
<Rect
|
||||||
|
x={chartData.eventLineX - 2}
|
||||||
|
y={PADDING.top - 2}
|
||||||
|
width={4}
|
||||||
|
height={4}
|
||||||
|
fill="#F59E0B"
|
||||||
|
rotation={45}
|
||||||
|
origin={`${chartData.eventLineX}, ${PADDING.top}`}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Svg>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
MiniChart.displayName = 'MiniChart';
|
||||||
|
|
||||||
|
export default MiniChart;
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 相关股票组件 - HeroUI 风格
|
* 相关股票组件 - HeroUI 风格
|
||||||
* 展示事件关联的股票列表
|
* 展示事件关联的股票列表
|
||||||
|
* 带有分时迷你图和事件时间标记
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { memo, useCallback } from 'react';
|
import React, { memo, useCallback, useState, useEffect } from 'react';
|
||||||
import { StyleSheet } from 'react-native';
|
import { StyleSheet } from 'react-native';
|
||||||
import * as ExpoClipboard from 'expo-clipboard';
|
import * as ExpoClipboard from 'expo-clipboard';
|
||||||
import {
|
import {
|
||||||
@@ -21,6 +22,8 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { gradients } from '../../theme';
|
import { gradients } from '../../theme';
|
||||||
import { StockWatchlistButton } from '../../components/AddWatchlistButton';
|
import { StockWatchlistButton } from '../../components/AddWatchlistButton';
|
||||||
|
import { stockDetailService } from '../../services/stockService';
|
||||||
|
import MiniChart from './MiniChart';
|
||||||
|
|
||||||
// 格式化涨跌幅
|
// 格式化涨跌幅
|
||||||
const formatChange = (value) => {
|
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 isLast = index === total - 1;
|
||||||
|
|
||||||
// 使用报价数据或股票数据
|
// 使用报价数据或股票数据
|
||||||
const stockName = quote?.name || stock.stock_name || stock.name || `股票`;
|
const stockName = quote?.name || stock.stock_name || stock.name || `股票`;
|
||||||
const stockCode = stock.stock_code || stock.code || '';
|
const stockCode = stock.stock_code || stock.code || '';
|
||||||
const price = quote?.price ?? stock.price;
|
|
||||||
const change = quote?.change ?? stock.change_percent;
|
const change = quote?.change ?? stock.change_percent;
|
||||||
const relationDesc = getRelationDesc(stock.relation_desc);
|
const relationDesc = getRelationDesc(stock.relation_desc);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Pressable
|
||||||
py={3}
|
onPress={() => onPress?.(stock)}
|
||||||
borderBottomWidth={isLast ? 0 : 1}
|
_pressed={{ opacity: 0.7 }}
|
||||||
borderBottomColor="rgba(255, 255, 255, 0.05)"
|
|
||||||
>
|
>
|
||||||
<HStack justifyContent="space-between" alignItems="flex-start">
|
<HStack
|
||||||
|
py={2.5}
|
||||||
|
borderBottomWidth={isLast ? 0 : 1}
|
||||||
|
borderBottomColor="rgba(255, 255, 255, 0.05)"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="space-between"
|
||||||
|
>
|
||||||
{/* 左侧:股票信息 */}
|
{/* 左侧:股票信息 */}
|
||||||
<Pressable
|
<VStack flex={1} mr={2}>
|
||||||
flex={1}
|
{/* 第一行:代码 + 名称 */}
|
||||||
mr={3}
|
<HStack alignItems="center" space={1.5}>
|
||||||
onPress={() => onPress?.(stock)}
|
<Text fontSize="xs" color="cyan.400" fontFamily="mono">
|
||||||
_pressed={{ opacity: 0.7 }}
|
{stockCode}
|
||||||
>
|
</Text>
|
||||||
<VStack>
|
<Text fontSize="sm" fontWeight="semibold" color="white" numberOfLines={1} flex={1}>
|
||||||
{/* 股票名称和代码在一行 */}
|
{stockName}
|
||||||
<HStack alignItems="center" space={2}>
|
</Text>
|
||||||
<Text fontSize="sm" fontWeight="bold" color="white">
|
</HStack>
|
||||||
{stockName}
|
{/* 第二行:涨跌幅 + 关联原因 */}
|
||||||
</Text>
|
<HStack alignItems="center" space={2} mt={0.5}>
|
||||||
<Text fontSize="xs" color="gray.500">
|
<Text
|
||||||
{stockCode}
|
fontSize="sm"
|
||||||
</Text>
|
fontWeight="bold"
|
||||||
{/* 复制按钮 */}
|
color={getChangeColor(change)}
|
||||||
<Pressable
|
>
|
||||||
onPress={(e) => {
|
{formatChange(change)}
|
||||||
e.stopPropagation?.();
|
</Text>
|
||||||
onCopyCode?.(stockCode);
|
<StockWatchlistButton
|
||||||
}}
|
stockCode={stockCode}
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
stockName={stockName}
|
||||||
_pressed={{ opacity: 0.5 }}
|
size="xs"
|
||||||
>
|
variant="icon"
|
||||||
<Icon as={Ionicons} name="copy-outline" size="xs" color="gray.500" />
|
/>
|
||||||
</Pressable>
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 关联原因 - 简洁显示 */}
|
|
||||||
{relationDesc && (
|
{relationDesc && (
|
||||||
<Text
|
<Text
|
||||||
fontSize="xs"
|
fontSize="2xs"
|
||||||
color="gray.400"
|
color="gray.500"
|
||||||
mt={1}
|
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
lineHeight="sm"
|
flex={1}
|
||||||
|
isTruncated
|
||||||
>
|
>
|
||||||
{relationDesc}
|
{relationDesc}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</VStack>
|
</HStack>
|
||||||
</Pressable>
|
</VStack>
|
||||||
|
|
||||||
{/* 右侧:涨跌幅、价格和加自选 */}
|
{/* 右侧:分时迷你图 */}
|
||||||
<HStack alignItems="center" space={2}>
|
<MiniChart
|
||||||
<VStack alignItems="flex-end" minW={16}>
|
data={minuteData?.data || []}
|
||||||
<Box
|
preClose={minuteData?.prevClose}
|
||||||
bg={getChangeBgColor(change)}
|
eventTime={eventTime}
|
||||||
rounded="lg"
|
loading={loadingMinute}
|
||||||
px={2}
|
/>
|
||||||
py={0.5}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
fontSize="xs"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={getChangeColor(change)}
|
|
||||||
>
|
|
||||||
{formatChange(change)}
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
{price != null && (
|
|
||||||
<Text fontSize="2xs" color="gray.500" mt={0.5}>
|
|
||||||
¥{price.toFixed(2)}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
{/* 加自选按钮 */}
|
|
||||||
<StockWatchlistButton
|
|
||||||
stockCode={stockCode}
|
|
||||||
stockName={stockName}
|
|
||||||
size="sm"
|
|
||||||
variant="icon"
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Pressable>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,8 +143,53 @@ const RelatedStocks = ({
|
|||||||
maxDisplay = 10,
|
maxDisplay = 10,
|
||||||
showAll = false,
|
showAll = false,
|
||||||
onShowAll,
|
onShowAll,
|
||||||
|
eventTime = null, // 事件发生时间,用于在分时图上标记
|
||||||
}) => {
|
}) => {
|
||||||
const toast = useToast();
|
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) => {
|
const handleCopyCode = useCallback(async (code) => {
|
||||||
@@ -259,17 +283,23 @@ const RelatedStocks = ({
|
|||||||
|
|
||||||
{/* 股票列表 */}
|
{/* 股票列表 */}
|
||||||
<VStack space={0}>
|
<VStack space={0}>
|
||||||
{displayStocks.map((stock, index) => (
|
{displayStocks.map((stock, index) => {
|
||||||
<StockItem
|
const stockCode = stock.stock_code || stock.code;
|
||||||
key={stock.id || stock.stock_code || index}
|
return (
|
||||||
stock={stock}
|
<StockItem
|
||||||
quote={quotes[stock.stock_code]}
|
key={stock.id || stockCode || index}
|
||||||
index={index}
|
stock={stock}
|
||||||
total={displayStocks.length}
|
quote={quotes[stockCode]}
|
||||||
onPress={onStockPress}
|
index={index}
|
||||||
onCopyCode={handleCopyCode}
|
total={displayStocks.length}
|
||||||
/>
|
onPress={onStockPress}
|
||||||
))}
|
onCopyCode={handleCopyCode}
|
||||||
|
minuteData={minuteDataMap[stockCode]}
|
||||||
|
eventTime={eventTime}
|
||||||
|
loadingMinute={loadingMinute && !minuteDataMap[stockCode]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
{/* 报价加载中提示 */}
|
{/* 报价加载中提示 */}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import ztService from '../../services/ztService';
|
|||||||
|
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
const CELL_WIDTH = (SCREEN_WIDTH - 32) / 7;
|
const CELL_WIDTH = (SCREEN_WIDTH - 32) / 7;
|
||||||
const CELL_HEIGHT = 95; // 增加高度以容纳跨天概念条
|
const CELL_HEIGHT = 75; // 精简布局后的高度
|
||||||
|
|
||||||
// 概念颜色调色板
|
// 概念颜色调色板
|
||||||
const CONCEPT_COLORS = [
|
const CONCEPT_COLORS = [
|
||||||
@@ -97,7 +97,7 @@ const isNextTradingDay = (date1, date2) => {
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 合并连续相同概念(跨周显示由分段逻辑处理)
|
// 合并连续相同概念(单天概念也保留显示)
|
||||||
const mergeConsecutiveConcepts = (calendarData, year, month) => {
|
const mergeConsecutiveConcepts = (calendarData, year, month) => {
|
||||||
const sorted = [...calendarData]
|
const sorted = [...calendarData]
|
||||||
.filter(d => d.topSector)
|
.filter(d => d.topSector)
|
||||||
@@ -119,8 +119,8 @@ const mergeConsecutiveConcepts = (calendarData, year, month) => {
|
|||||||
currentEvent.endDate = item.date;
|
currentEvent.endDate = item.date;
|
||||||
currentEvent.dates.push(item.date);
|
currentEvent.dates.push(item.date);
|
||||||
} else {
|
} else {
|
||||||
// 保存之前的事件(如果有多天)
|
// 保存之前的事件(包括单天的)
|
||||||
if (currentEvent && currentEvent.dates.length > 1) {
|
if (currentEvent) {
|
||||||
events.push(currentEvent);
|
events.push(currentEvent);
|
||||||
}
|
}
|
||||||
// 开始新事件
|
// 开始新事件
|
||||||
@@ -133,8 +133,8 @@ const mergeConsecutiveConcepts = (calendarData, year, month) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 保存最后一个事件
|
// 保存最后一个事件(包括单天的)
|
||||||
if (currentEvent && currentEvent.dates.length > 1) {
|
if (currentEvent) {
|
||||||
events.push(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)'}
|
bg={isToday ? 'rgba(212, 175, 55, 0.15)' : 'rgba(15, 15, 22, 0.4)'}
|
||||||
p={1}
|
p={1}
|
||||||
>
|
>
|
||||||
{/* 第一行:日期 + 涨跌幅 */}
|
{/* 日期数字 */}
|
||||||
<HStack justifyContent="space-between" alignItems="flex-start">
|
<Text
|
||||||
<Text
|
fontSize="md"
|
||||||
fontSize="md"
|
fontWeight={isToday ? 'bold' : '600'}
|
||||||
fontWeight={isToday ? 'bold' : '600'}
|
color={isToday ? '#FFD700' : isWeekend ? '#FB923C' : 'white'}
|
||||||
color={isToday ? '#FFD700' : isWeekend ? '#FB923C' : 'white'}
|
textAlign="center"
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
</Text>
|
</Text>
|
||||||
{data?.indexChange !== null && data?.indexChange !== undefined && (
|
|
||||||
<Text
|
|
||||||
fontSize="xs"
|
|
||||||
fontWeight="bold"
|
|
||||||
color={data.indexChange >= 0 ? '#EF4444' : '#22C55E'}
|
|
||||||
>
|
|
||||||
{data.indexChange >= 0 ? '+' : ''}{data.indexChange?.toFixed(2)}%
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
{/* 涨停数据 */}
|
{/* 涨停数 + 事件数(合并一行) */}
|
||||||
{data?.ztCount > 0 && (
|
{(data?.ztCount > 0 || data?.eventCount > 0) && (
|
||||||
<HStack alignItems="center" justifyContent="center" mt={1}>
|
<HStack alignItems="center" justifyContent="center" mt={1} space={1}>
|
||||||
<FlameIcon
|
{/* 涨停数 */}
|
||||||
color={data.ztCount >= 60 ? '#EF4444' : '#F59E0B'}
|
{data?.ztCount > 0 && (
|
||||||
size={14}
|
<HStack alignItems="center">
|
||||||
/>
|
<FlameIcon
|
||||||
<Text
|
color={data.ztCount >= 60 ? '#EF4444' : '#F59E0B'}
|
||||||
fontSize="sm"
|
size={12}
|
||||||
fontWeight="bold"
|
/>
|
||||||
color={data.ztCount >= 60 ? '#EF4444' : '#F59E0B'}
|
<Text
|
||||||
ml={1}
|
fontSize="xs"
|
||||||
>
|
fontWeight="bold"
|
||||||
{data.ztCount}
|
color={data.ztCount >= 60 ? '#EF4444' : '#F59E0B'}
|
||||||
</Text>
|
>
|
||||||
</HStack>
|
{data.ztCount}
|
||||||
)}
|
</Text>
|
||||||
|
</HStack>
|
||||||
{/* 事件数量 */}
|
)}
|
||||||
{data?.eventCount > 0 && (
|
{/* 事件数 */}
|
||||||
<HStack alignItems="center" justifyContent="center" mt={1}>
|
{data?.eventCount > 0 && (
|
||||||
<Box
|
<HStack alignItems="center">
|
||||||
bg="rgba(34, 197, 94, 0.9)"
|
<Box
|
||||||
rounded="full"
|
bg="#22C55E"
|
||||||
w={4}
|
rounded="full"
|
||||||
h={4}
|
w={3.5}
|
||||||
alignItems="center"
|
h={3.5}
|
||||||
justifyContent="center"
|
alignItems="center"
|
||||||
mr={1}
|
justifyContent="center"
|
||||||
>
|
>
|
||||||
<Text fontSize="2xs" fontWeight="bold" color="white">
|
<Text fontSize="7px" fontWeight="bold" color="white">
|
||||||
{data.eventCount}
|
{data.eventCount}
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fontSize="2xs" color="#22C55E" fontWeight="600">
|
</HStack>
|
||||||
事件
|
)}
|
||||||
</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
@@ -453,10 +442,10 @@ const EventCalendar = ({ navigation }) => {
|
|||||||
// 计算位置和尺寸
|
// 计算位置和尺寸
|
||||||
const left = startCol * CELL_WIDTH + 2;
|
const left = startCol * CELL_WIDTH + 2;
|
||||||
const width = (endCol - startCol + 1) * CELL_WIDTH - 4;
|
const width = (endCol - startCol + 1) * CELL_WIDTH - 4;
|
||||||
// 根据堆叠索引调整垂直位置(每个概念条高度 20,间隔 2)
|
// 根据堆叠索引调整垂直位置(每个概念条高度 18,间隔 2)
|
||||||
const barHeight = 20;
|
const barHeight = 18;
|
||||||
const verticalOffset = stackIndex * (barHeight + 2);
|
const verticalOffset = stackIndex * (barHeight + 2);
|
||||||
const top = row * CELL_HEIGHT + CELL_HEIGHT - 26 - verticalOffset;
|
const top = row * CELL_HEIGHT + CELL_HEIGHT - 22 - verticalOffset;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box
|
<Box
|
||||||
@@ -475,22 +464,22 @@ const EventCalendar = ({ navigation }) => {
|
|||||||
styles.conceptBar,
|
styles.conceptBar,
|
||||||
{
|
{
|
||||||
// 根据是否是起始/结束段调整圆角
|
// 根据是否是起始/结束段调整圆角
|
||||||
borderTopLeftRadius: isStart ? 6 : 0,
|
borderTopLeftRadius: isStart ? 5 : 0,
|
||||||
borderBottomLeftRadius: isStart ? 6 : 0,
|
borderBottomLeftRadius: isStart ? 5 : 0,
|
||||||
borderTopRightRadius: segment.isEnd ? 6 : 0,
|
borderTopRightRadius: segment.isEnd ? 5 : 0,
|
||||||
borderBottomRightRadius: segment.isEnd ? 6 : 0,
|
borderBottomRightRadius: segment.isEnd ? 5 : 0,
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
fontSize="2xs"
|
fontSize="9px"
|
||||||
fontWeight="bold"
|
fontWeight="bold"
|
||||||
color={color.text}
|
color={color.text}
|
||||||
numberOfLines={1}
|
numberOfLines={1}
|
||||||
>
|
>
|
||||||
{concept}
|
{concept}
|
||||||
{totalDays > 1 && isStart && (
|
{totalDays > 1 && isStart && (
|
||||||
<Text fontSize="2xs" opacity={0.8}>
|
<Text fontSize="8px" opacity={0.8}>
|
||||||
({totalDays}天)
|
({totalDays}天)
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -661,7 +650,7 @@ const EventCalendar = ({ navigation }) => {
|
|||||||
rounded="xl"
|
rounded="xl"
|
||||||
p={3}
|
p={3}
|
||||||
>
|
>
|
||||||
<HStack justifyContent="center" space={3} flexWrap="wrap">
|
<HStack justifyContent="center" space={4} flexWrap="wrap">
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={['#FFD700', '#FFA500']}
|
colors={['#FFD700', '#FFA500']}
|
||||||
@@ -669,15 +658,11 @@ const EventCalendar = ({ navigation }) => {
|
|||||||
end={{ x: 1, y: 0 }}
|
end={{ x: 1, y: 0 }}
|
||||||
style={styles.legendBar}
|
style={styles.legendBar}
|
||||||
/>
|
/>
|
||||||
<Text fontSize="2xs" color="gray.400" ml={1}>连续热门概念</Text>
|
<Text fontSize="2xs" color="gray.400" ml={1}>热门概念</Text>
|
||||||
</HStack>
|
|
||||||
<HStack alignItems="center">
|
|
||||||
<FlameIcon color="#EF4444" size={12} />
|
|
||||||
<Text fontSize="2xs" color="gray.400" ml={1}>涨停≥60</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
<FlameIcon color="#F59E0B" size={12} />
|
<FlameIcon color="#F59E0B" size={12} />
|
||||||
<Text fontSize="2xs" color="gray.400" ml={1}>涨停<60</Text>
|
<Text fontSize="2xs" color="gray.400" ml={1}>涨停数</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack alignItems="center">
|
<HStack alignItems="center">
|
||||||
<Box
|
<Box
|
||||||
@@ -690,13 +675,7 @@ const EventCalendar = ({ navigation }) => {
|
|||||||
>
|
>
|
||||||
<Text fontSize="6px" fontWeight="bold" color="white">N</Text>
|
<Text fontSize="6px" fontWeight="bold" color="white">N</Text>
|
||||||
</Box>
|
</Box>
|
||||||
<Text fontSize="2xs" color="gray.400" ml={1}>未来事件</Text>
|
<Text fontSize="2xs" color="gray.400" ml={1}>事件数</Text>
|
||||||
</HStack>
|
|
||||||
<HStack alignItems="center">
|
|
||||||
<Text fontSize="2xs" fontWeight="600" color="#EF4444">+0.5%</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.500" mx={0.5}>/</Text>
|
|
||||||
<Text fontSize="2xs" fontWeight="600" color="#22C55E">-0.5%</Text>
|
|
||||||
<Text fontSize="2xs" color="gray.400" ml={1}>上证涨跌</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
</Box>
|
</Box>
|
||||||
@@ -721,10 +700,10 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
},
|
},
|
||||||
conceptBar: {
|
conceptBar: {
|
||||||
height: 20,
|
height: 18,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: 4,
|
||||||
},
|
},
|
||||||
legendBar: {
|
legendBar: {
|
||||||
width: 20,
|
width: 20,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { stockDetailService } from '../../services/stockService';
|
|||||||
|
|
||||||
import { useWatchlist } from '../../hooks/useWatchlist';
|
import { useWatchlist } from '../../hooks/useWatchlist';
|
||||||
import { useSingleQuote } from '../../hooks/useRealtimeQuote';
|
import { useSingleQuote } from '../../hooks/useRealtimeQuote';
|
||||||
|
import AddWatchlistModal from '../Watchlist/AddWatchlistModal';
|
||||||
import {
|
import {
|
||||||
fetchStockDetail,
|
fetchStockDetail,
|
||||||
fetchMinuteData,
|
fetchMinuteData,
|
||||||
@@ -64,6 +65,7 @@ const StockDetailScreen = () => {
|
|||||||
const [selectedAnalysis, setSelectedAnalysis] = useState(null);
|
const [selectedAnalysis, setSelectedAnalysis] = useState(null);
|
||||||
const [analysisModalOpen, setAnalysisModalOpen] = useState(false);
|
const [analysisModalOpen, setAnalysisModalOpen] = useState(false);
|
||||||
const [fallbackOrderBook, setFallbackOrderBook] = useState(null); // API 降级盘口数据
|
const [fallbackOrderBook, setFallbackOrderBook] = useState(null); // API 降级盘口数据
|
||||||
|
const [searchModalOpen, setSearchModalOpen] = useState(false); // 搜索弹窗
|
||||||
|
|
||||||
// Redux 状态
|
// Redux 状态
|
||||||
const currentStock = useSelector(selectCurrentStock);
|
const currentStock = useSelector(selectCurrentStock);
|
||||||
@@ -251,6 +253,16 @@ const StockDetailScreen = () => {
|
|||||||
setSelectedAnalysis(null);
|
setSelectedAnalysis(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 打开搜索弹窗
|
||||||
|
const handleOpenSearch = useCallback(() => {
|
||||||
|
setSearchModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 关闭搜索弹窗
|
||||||
|
const handleCloseSearch = useCallback(() => {
|
||||||
|
setSearchModalOpen(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 获取当前图表数据
|
// 获取当前图表数据
|
||||||
const currentChartData = useMemo(() => {
|
const currentChartData = useMemo(() => {
|
||||||
if (chartType === 'minute') {
|
if (chartType === 'minute') {
|
||||||
@@ -293,6 +305,7 @@ const StockDetailScreen = () => {
|
|||||||
isInWatchlist={inWatchlist}
|
isInWatchlist={inWatchlist}
|
||||||
onToggleWatchlist={handleToggleWatchlist}
|
onToggleWatchlist={handleToggleWatchlist}
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
|
onSearch={handleOpenSearch}
|
||||||
isRealtime={wsConnected && !!realtimeQuote?.current_price}
|
isRealtime={wsConnected && !!realtimeQuote?.current_price}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -357,6 +370,12 @@ const StockDetailScreen = () => {
|
|||||||
onClose={handleCloseAnalysisModal}
|
onClose={handleCloseAnalysisModal}
|
||||||
analysis={selectedAnalysis}
|
analysis={selectedAnalysis}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 搜索股票弹窗 */}
|
||||||
|
<AddWatchlistModal
|
||||||
|
isOpen={searchModalOpen}
|
||||||
|
onClose={handleCloseSearch}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ const PriceHeader = memo(({
|
|||||||
isInWatchlist,
|
isInWatchlist,
|
||||||
onToggleWatchlist,
|
onToggleWatchlist,
|
||||||
onBack,
|
onBack,
|
||||||
|
onSearch, // 搜索按钮点击回调
|
||||||
isRealtime = false, // 是否正在接收实时数据
|
isRealtime = false, // 是否正在接收实时数据
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
@@ -154,7 +155,7 @@ const PriceHeader = memo(({
|
|||||||
color={isInWatchlist ? '#F59E0B' : 'gray.400'}
|
color={isInWatchlist ? '#F59E0B' : 'gray.400'}
|
||||||
/>
|
/>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Pressable hitSlop={10}>
|
<Pressable onPress={onSearch} hitSlop={10}>
|
||||||
<Icon as={Ionicons} name="search" size="sm" color="gray.400" />
|
<Icon as={Ionicons} name="search" size="sm" color="gray.400" />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -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 { Keyboard, ActivityIndicator, Dimensions } from 'react-native';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
@@ -26,10 +26,35 @@ const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
|||||||
// 列表项高度常量(用于 getItemLayout 优化)
|
// 列表项高度常量(用于 getItemLayout 优化)
|
||||||
const ITEM_HEIGHT = 56;
|
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 避免不必要的重渲染
|
// 搜索结果项组件 - 使用 memo 避免不必要的重渲染
|
||||||
const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => {
|
const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded, quote }) => {
|
||||||
const { stock_code, stock_name, industry } = item;
|
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(() => {
|
const handlePress = useCallback(() => {
|
||||||
if (!alreadyAdded && !isAdding) {
|
if (!alreadyAdded && !isAdding) {
|
||||||
onAdd(item);
|
onAdd(item);
|
||||||
@@ -50,6 +75,7 @@ const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => {
|
|||||||
bg={pressed && !alreadyAdded ? 'rgba(255,255,255,0.05)' : 'transparent'}
|
bg={pressed && !alreadyAdded ? 'rgba(255,255,255,0.05)' : 'transparent'}
|
||||||
opacity={alreadyAdded ? 0.5 : 1}
|
opacity={alreadyAdded ? 0.5 : 1}
|
||||||
>
|
>
|
||||||
|
{/* 左侧:股票名称和代码 */}
|
||||||
<VStack flex={1}>
|
<VStack flex={1}>
|
||||||
<Text color="white" fontSize={15} fontWeight="medium">
|
<Text color="white" fontSize={15} fontWeight="medium">
|
||||||
{stock_name}
|
{stock_name}
|
||||||
@@ -66,6 +92,17 @@ const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
</VStack>
|
</VStack>
|
||||||
|
|
||||||
|
{/* 中间:价格和涨跌幅 */}
|
||||||
|
<VStack alignItems="flex-end" mr={3} minW={70}>
|
||||||
|
<Text color={changeColor} fontSize={14} fontWeight="medium">
|
||||||
|
{formatPrice(price)}
|
||||||
|
</Text>
|
||||||
|
<Text color={changeColor} fontSize={11}>
|
||||||
|
{formatChange(changePercent)}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
|
||||||
|
{/* 右侧:操作按钮 */}
|
||||||
{alreadyAdded ? (
|
{alreadyAdded ? (
|
||||||
<HStack alignItems="center" space={1}>
|
<HStack alignItems="center" space={1}>
|
||||||
<Icon
|
<Icon
|
||||||
@@ -129,12 +166,35 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
|||||||
const [searchResults, setSearchResults] = useState([]);
|
const [searchResults, setSearchResults] = useState([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [addingCode, setAddingCode] = useState(null);
|
const [addingCode, setAddingCode] = useState(null);
|
||||||
|
const [quotes, setQuotes] = useState({}); // 行情数据缓存
|
||||||
const searchTimeoutRef = useRef(null);
|
const searchTimeoutRef = useRef(null);
|
||||||
const abortControllerRef = useRef(null);
|
const abortControllerRef = useRef(null);
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
const { addStock, isInWatchlist, stocks } = useWatchlist({ autoLoad: false });
|
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 用于快速查找
|
// 创建已添加股票代码的 Set 用于快速查找
|
||||||
const watchlistCodesSet = useMemo(() => {
|
const watchlistCodesSet = useMemo(() => {
|
||||||
const normalizeCode = (code) => String(code).match(/\d{6}/)?.[0] || code;
|
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);
|
const response = await stockDetailService.searchStocks(text, 20);
|
||||||
if (response.success && Array.isArray(response.data)) {
|
if (response.success && Array.isArray(response.data)) {
|
||||||
setSearchResults(response.data);
|
setSearchResults(response.data);
|
||||||
|
// 获取搜索结果的行情数据
|
||||||
|
const codes = response.data.map(s => s.stock_code).filter(Boolean);
|
||||||
|
if (codes.length > 0) {
|
||||||
|
fetchQuotes(codes);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
}
|
}
|
||||||
@@ -188,7 +253,7 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
|||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
}
|
}
|
||||||
}, 200);
|
}, 200);
|
||||||
}, []);
|
}, [fetchQuotes]);
|
||||||
|
|
||||||
// 添加到自选
|
// 添加到自选
|
||||||
const handleAdd = useCallback(async (stock) => {
|
const handleAdd = useCallback(async (stock) => {
|
||||||
@@ -234,6 +299,7 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
|||||||
setSearchText('');
|
setSearchText('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
|
setQuotes({}); // 清理行情缓存
|
||||||
onClose?.();
|
onClose?.();
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
@@ -253,14 +319,24 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
|||||||
|
|
||||||
const keyExtractor = useCallback((item) => item.stock_code, []);
|
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 }) => (
|
const renderItem = useCallback(({ item }) => (
|
||||||
<SearchResultItem
|
<SearchResultItem
|
||||||
item={item}
|
item={item}
|
||||||
onAdd={handleAdd}
|
onAdd={handleAdd}
|
||||||
isAdding={addingCode === item.stock_code}
|
isAdding={addingCode === item.stock_code}
|
||||||
alreadyAdded={checkInWatchlist(item.stock_code)}
|
alreadyAdded={checkInWatchlist(item.stock_code)}
|
||||||
|
quote={getQuote(item.stock_code)}
|
||||||
/>
|
/>
|
||||||
), [handleAdd, addingCode, checkInWatchlist]);
|
), [handleAdd, addingCode, checkInWatchlist, getQuote]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|||||||
@@ -470,17 +470,22 @@ class RealtimeQuoteService {
|
|||||||
|
|
||||||
// ============ 私有方法 ============
|
// ============ 私有方法 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有已订阅的股票代码
|
||||||
|
*/
|
||||||
|
_getSubscribedCodes() {
|
||||||
|
const codes = new Set();
|
||||||
|
// 合并上交所和深交所的订阅
|
||||||
|
this.managers.sse.subscriptions.forEach(code => codes.add(code));
|
||||||
|
this.managers.szse.subscriptions.forEach(code => codes.add(code));
|
||||||
|
return codes;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理行情消息(参考 Web 端格式)
|
* 处理行情消息(参考 Web 端格式)
|
||||||
* 消息格式: { type: 'stock' | 'index', data: { code: quote, ... } }
|
* 消息格式: { type: 'stock' | 'index', data: { code: quote, ... } }
|
||||||
*/
|
*/
|
||||||
_handleQuoteMessage(message, exchange) {
|
_handleQuoteMessage(message, exchange) {
|
||||||
// 调试:打印收到的原始消息
|
|
||||||
if (this._msgLogCount < 5) {
|
|
||||||
console.log(`[RealtimeQuote] 收到${exchange}消息:`, JSON.stringify(message).substring(0, 800));
|
|
||||||
this._msgLogCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 心跳响应
|
// 心跳响应
|
||||||
if (message.type === 'pong') return;
|
if (message.type === 'pong') return;
|
||||||
|
|
||||||
@@ -499,9 +504,35 @@ class RealtimeQuoteService {
|
|||||||
// 处理行情数据
|
// 处理行情数据
|
||||||
// 格式: { type: 'stock' | 'index', data: { '603199': {...}, ... } }
|
// 格式: { type: 'stock' | 'index', data: { '603199': {...}, ... } }
|
||||||
if ((message.type === 'stock' || message.type === 'index') && message.data) {
|
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) {
|
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);
|
this._notifyQuoteHandlers(quotes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -266,15 +266,15 @@ export const ztService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 快速获取日历数据(从 API 获取完整信息包括 top_sector)
|
* 快速获取日历数据(从 combined-data API 获取完整信息包括 top_sector)
|
||||||
* @param {number} year - 年份
|
* @param {number} year - 年份
|
||||||
* @param {number} month - 月份 (1-12)
|
* @param {number} month - 月份 (1-12)
|
||||||
* @returns {Promise<object>} 日历数据
|
* @returns {Promise<object>} 日历数据
|
||||||
*/
|
*/
|
||||||
getCalendarDataFast: async (year, month) => {
|
getCalendarDataFast: async (year, month) => {
|
||||||
try {
|
try {
|
||||||
// 使用后端日历 API 获取包含 top_sector 的完整数据
|
// 使用 combined-data API(与 Web 端一致)
|
||||||
const response = await apiRequest(`/api/zt/calendar?year=${year}&month=${month}`);
|
const response = await apiRequest(`/api/v1/calendar/combined-data?year=${year}&month=${month}`);
|
||||||
|
|
||||||
if (response.success && response.data) {
|
if (response.success && response.data) {
|
||||||
const calendarData = response.data.map(d => ({
|
const calendarData = response.data.map(d => ({
|
||||||
|
|||||||
@@ -235,8 +235,10 @@ const eventsSlice = createSlice({
|
|||||||
if (refresh || pagination.page === 1) {
|
if (refresh || pagination.page === 1) {
|
||||||
state.events = events;
|
state.events = events;
|
||||||
} else {
|
} 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.pagination = pagination;
|
||||||
state.loading.events = false;
|
state.loading.events = false;
|
||||||
|
|||||||
192
app.py
192
app.py
@@ -505,6 +505,15 @@ STOCK_NAME_EXPIRE = 86400 # 股票名称缓存24小时
|
|||||||
PREV_CLOSE_PREFIX = "vf:stock:prev_close:" # 前收盘价缓存前缀
|
PREV_CLOSE_PREFIX = "vf:stock:prev_close:" # 前收盘价缓存前缀
|
||||||
PREV_CLOSE_EXPIRE = 86400 # 前收盘价缓存24小时(当日有效)
|
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):
|
def get_cached_stock_names(base_codes):
|
||||||
"""
|
"""
|
||||||
@@ -623,6 +632,119 @@ def get_cached_prev_close(base_codes, trade_date_str):
|
|||||||
return result
|
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():
|
def preload_stock_cache():
|
||||||
"""
|
"""
|
||||||
预热股票缓存(定时任务,每天 9:25 执行)
|
预热股票缓存(定时任务,每天 9:25 执行)
|
||||||
@@ -8786,16 +8908,29 @@ def get_flex_screen_quotes():
|
|||||||
if not codes:
|
if not codes:
|
||||||
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
|
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()
|
client = get_clickhouse_client()
|
||||||
results = {}
|
|
||||||
source = 'realtime'
|
source = 'realtime'
|
||||||
|
|
||||||
# 分离上交所和深交所代码
|
# 分离上交所和深交所代码(只处理未命中缓存的)
|
||||||
sse_codes = [] # 上交所
|
sse_codes = [] # 上交所
|
||||||
szse_stock_codes = [] # 深交所股票
|
szse_stock_codes = [] # 深交所股票
|
||||||
szse_index_codes = [] # 深交所指数
|
szse_index_codes = [] # 深交所指数
|
||||||
|
|
||||||
for code in codes:
|
for code in missing_codes:
|
||||||
base_code = code.split('.')[0]
|
base_code = code.split('.')[0]
|
||||||
if code.endswith('.SH'):
|
if code.endswith('.SH'):
|
||||||
sse_codes.append(base_code)
|
sse_codes.append(base_code)
|
||||||
@@ -8806,17 +8941,9 @@ def get_flex_screen_quotes():
|
|||||||
else:
|
else:
|
||||||
szse_stock_codes.append(base_code)
|
szse_stock_codes.append(base_code)
|
||||||
|
|
||||||
# 获取股票名称
|
# 获取股票名称(只查询缺失代码,使用缓存)
|
||||||
stock_names = {}
|
base_codes = list(set([code.split('.')[0] for code in missing_codes]))
|
||||||
with engine.connect() as conn:
|
stock_names = get_cached_stock_names(base_codes) if base_codes else {}
|
||||||
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}
|
|
||||||
|
|
||||||
# 查询深交所股票实时行情
|
# 查询深交所股票实时行情
|
||||||
if szse_stock_codes:
|
if szse_stock_codes:
|
||||||
@@ -9078,10 +9205,15 @@ def get_flex_screen_quotes():
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"查询分钟线数据失败: {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({
|
return jsonify({
|
||||||
'success': True,
|
'success': True,
|
||||||
'data': results,
|
'data': results,
|
||||||
'source': source
|
'source': source if not cached_results else 'mixed'
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -9665,9 +9797,8 @@ def get_latest_minute_data(stock_code):
|
|||||||
- 如果是当天交易日且在交易时间内,只返回到当前时间的数据
|
- 如果是当天交易日且在交易时间内,只返回到当前时间的数据
|
||||||
- 返回昨收价 prev_close,供前端计算涨跌幅
|
- 返回昨收价 prev_close,供前端计算涨跌幅
|
||||||
- 返回 is_trading 标志指示当前是否在交易中
|
- 返回 is_trading 标志指示当前是否在交易中
|
||||||
|
- 使用 Redis 缓存减少 ClickHouse 查询压力
|
||||||
"""
|
"""
|
||||||
client = get_clickhouse_client()
|
|
||||||
|
|
||||||
# 确保股票代码包含后缀
|
# 确保股票代码包含后缀
|
||||||
if '.' not in stock_code:
|
if '.' not in stock_code:
|
||||||
if stock_code.startswith('6'):
|
if stock_code.startswith('6'):
|
||||||
@@ -9679,14 +9810,17 @@ def get_latest_minute_data(stock_code):
|
|||||||
|
|
||||||
base_code = stock_code.split('.')[0]
|
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
|
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天内有数据的最新交易日
|
# 查找最近30天内有数据的最新交易日
|
||||||
target_date = None
|
target_date = None
|
||||||
@@ -9827,7 +9961,8 @@ def get_latest_minute_data(stock_code):
|
|||||||
'change_pct': round(calculated_change_pct, 2)
|
'change_pct': round(calculated_change_pct, 2)
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({
|
# 构建响应数据
|
||||||
|
response_data = {
|
||||||
'code': stock_code,
|
'code': stock_code,
|
||||||
'name': stock_name,
|
'name': stock_name,
|
||||||
'data': kline_data,
|
'data': kline_data,
|
||||||
@@ -9836,7 +9971,12 @@ def get_latest_minute_data(stock_code):
|
|||||||
'is_latest': True,
|
'is_latest': True,
|
||||||
'prev_close': prev_close,
|
'prev_close': prev_close,
|
||||||
'is_trading': is_trading
|
'is_trading': is_trading
|
||||||
})
|
}
|
||||||
|
|
||||||
|
# ==================== 写入 Redis 缓存 ====================
|
||||||
|
set_cached_minute_data(stock_code, response_data)
|
||||||
|
|
||||||
|
return jsonify(response_data)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/stock/<stock_code>/forecast-report', methods=['GET'])
|
@app.route('/api/stock/<stock_code>/forecast-report', methods=['GET'])
|
||||||
|
|||||||
Reference in New Issue
Block a user