更新ios

This commit is contained in:
2026-01-20 17:29:24 +08:00
parent 3513a46f03
commit b2bfcd3482
12 changed files with 783 additions and 221 deletions

View File

@@ -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 {

View File

@@ -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}
/> />
{/* 市场影响卡片 */} {/* 市场影响卡片 */}

View 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:30120分钟+ 13:00-15:00120分钟= 总共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;

View File

@@ -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>
{/* 报价加载中提示 */} {/* 报价加载中提示 */}

View File

@@ -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}>涨停&lt;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,

View File

@@ -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>
); );

View File

@@ -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>

View File

@@ -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

View File

@@ -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);
} }
} }

View File

@@ -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 => ({

View File

@@ -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
View File

@@ -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'])