update pay ui

This commit is contained in:
2025-12-03 08:02:49 +08:00
parent a14313fdbd
commit 5ff68d0790
7 changed files with 491 additions and 84 deletions

View File

@@ -358,6 +358,37 @@ export const stockService = {
throw error;
}
},
/**
* 批量获取多只股票的K线数据
* @param {string[]} stockCodes - 股票代码数组
* @param {string} chartType - 图表类型 (timeline/daily)
* @param {string} eventTime - 事件时间
* @returns {Promise<Object>} { [stockCode]: data[] }
*/
getBatchKlineData: async (stockCodes, chartType = 'timeline', eventTime = null) => {
try {
const requestBody = {
codes: stockCodes,
type: chartType
};
if (eventTime) {
requestBody.event_time = eventTime;
}
logger.debug('stockService', '批量获取K线数据', { stockCount: stockCodes.length, chartType, eventTime });
const response = await apiRequest('/api/stock/batch-kline', {
method: 'POST',
body: JSON.stringify(requestBody)
});
return response;
} catch (error) {
logger.error('stockService', 'getBatchKlineData', error, { stockCodes, chartType });
throw error;
}
},
getTransmissionChainAnalysis: async (eventId) => {
return await apiRequest(`/api/events/${eventId}/transmission`);
},

View File

@@ -122,8 +122,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// 相关股票默认折叠,只显示数量吸引点击
const [isStocksOpen, setIsStocksOpen] = useState(false);
// 相关股票默认展开
const [isStocksOpen, setIsStocksOpen] = useState(true);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载

View File

@@ -1,6 +1,6 @@
// src/views/Community/components/EventCard/EventPriceDisplay.js
import React from 'react';
import { HStack, Badge, Text, Tooltip } from '@chakra-ui/react';
import React, { useState } from 'react';
import { HStack, Box, Text, Tooltip, Progress } from '@chakra-ui/react';
import { PriceArrow } from '../../../../utils/priceFormatters';
/**
@@ -8,17 +8,20 @@ import { PriceArrow } from '../../../../utils/priceFormatters';
* @param {Object} props
* @param {number|null} props.avgChange - 平均涨跌幅
* @param {number|null} props.maxChange - 最大涨跌幅
* @param {number|null} props.weekChange - 周涨跌幅
* @param {number|null} props.expectationScore - 超预期得分满分100
* @param {boolean} props.compact - 是否为紧凑模式(只显示平均值,默认 false
* @param {boolean} props.inline - 是否内联显示(默认 false
*/
const EventPriceDisplay = ({
avgChange,
maxChange,
weekChange,
expectationScore,
compact = false,
inline = false
}) => {
// 点击切换显示最大超额/平均超额
const [showAvg, setShowAvg] = useState(false);
// 获取颜色方案
const getColorScheme = (value) => {
if (value == null) return 'gray';
@@ -31,12 +34,23 @@ const EventPriceDisplay = ({
return `${value > 0 ? '+' : ''}${value.toFixed(2)}%`;
};
// 获取超预期得分的颜色(渐变色系)
const getScoreColor = (score) => {
if (score == null) return { bg: 'gray.100', color: 'gray.500', progressColor: 'gray' };
if (score >= 80) return { bg: 'red.50', color: 'red.600', progressColor: 'red' };
if (score >= 60) return { bg: 'orange.50', color: 'orange.600', progressColor: 'orange' };
if (score >= 40) return { bg: 'yellow.50', color: 'yellow.700', progressColor: 'yellow' };
if (score >= 20) return { bg: 'blue.50', color: 'blue.600', progressColor: 'blue' };
return { bg: 'gray.50', color: 'gray.600', progressColor: 'gray' };
};
// 紧凑模式:只显示平均值,内联在标题后
if (compact && avgChange != null) {
return (
<Tooltip label="平均" placement="top">
<Badge
colorScheme={getColorScheme(avgChange)}
<Tooltip label="平均超额" placement="top">
<Box
bg={avgChange > 0 ? 'red.50' : avgChange < 0 ? 'green.50' : 'gray.100'}
color={avgChange > 0 ? 'red.600' : avgChange < 0 ? 'green.600' : 'gray.500'}
fontSize="xs"
px={2}
py={1}
@@ -49,71 +63,91 @@ const EventPriceDisplay = ({
>
<PriceArrow value={avgChange} />
{formatPercent(avgChange)}
</Badge>
</Box>
</Tooltip>
);
}
// 详细模式:显示所有价格变动
const displayValue = showAvg ? avgChange : maxChange;
const displayLabel = showAvg ? '平均超额' : '最大超额';
const scoreColors = getScoreColor(expectationScore);
// 详细模式:显示最大超额(可点击切换)+ 超预期得分
return (
<HStack spacing={2} flexWrap="wrap">
{/* 平均涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={getColorScheme(avgChange)}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
<HStack spacing={3} flexWrap="wrap">
{/* 最大超额/平均超额 - 点击切换 */}
<Tooltip
label={showAvg ? "点击查看最大超额" : "点击查看平均超额"}
placement="top"
hasArrow
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}>平均</Text>
<Text fontWeight="bold">
{formatPercent(avgChange)}
</Text>
</HStack>
</Badge>
<Box
bg={displayValue > 0 ? 'red.50' : displayValue < 0 ? 'green.50' : 'gray.100'}
color={displayValue > 0 ? 'red.600' : displayValue < 0 ? 'green.600' : 'gray.500'}
fontSize="xs"
px={2.5}
py={1}
borderRadius="md"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
setShowAvg(!showAvg);
}}
_hover={{
transform: 'scale(1.02)',
boxShadow: 'sm',
opacity: 0.9
}}
transition="all 0.2s"
border="1px solid"
borderColor={displayValue > 0 ? 'red.200' : displayValue < 0 ? 'green.200' : 'gray.200'}
>
<HStack spacing={1.5}>
<Text fontSize="xs" opacity={0.7} fontWeight="medium">{displayLabel}</Text>
<Text fontWeight="bold" fontSize="sm">
{formatPercent(displayValue)}
</Text>
</HStack>
</Box>
</Tooltip>
{/* 最大涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={getColorScheme(maxChange)}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}>最大</Text>
<Text fontWeight="bold">
{formatPercent(maxChange)}
</Text>
</HStack>
</Badge>
{/* 周涨幅 - 始终显示,无数据时显示 -- */}
<Badge
colorScheme={getColorScheme(weekChange)}
fontSize="xs"
px={2}
py={0.5}
borderRadius="md"
cursor="pointer"
_hover={{ transform: 'scale(1.05)', boxShadow: 'md' }}
transition="all 0.2s"
>
<HStack spacing={1}>
<Text fontSize="xs" opacity={0.8}></Text>
{weekChange != null && <PriceArrow value={weekChange} />}
<Text fontWeight="bold">
{formatPercent(weekChange)}
</Text>
</HStack>
</Badge>
{/* 超预期得分 - 精致的进度条样式 */}
{expectationScore != null && (
<Tooltip
label={`超预期得分:${expectationScore.toFixed(0)}满分100分`}
placement="top"
hasArrow
>
<Box
bg={scoreColors.bg}
px={2.5}
py={1}
borderRadius="md"
border="1px solid"
borderColor={`${scoreColors.progressColor}.200`}
minW="90px"
>
<HStack spacing={2}>
<Text fontSize="xs" color={scoreColors.color} fontWeight="medium" opacity={0.8}>
超预期
</Text>
<Box flex={1} minW="40px">
<Progress
value={expectationScore}
max={100}
size="xs"
colorScheme={scoreColors.progressColor}
borderRadius="full"
bg={`${scoreColors.progressColor}.100`}
/>
</Box>
<Text fontSize="xs" fontWeight="bold" color={scoreColors.color}>
{expectationScore.toFixed(0)}
</Text>
</HStack>
</Box>
</Tooltip>
)}
</HStack>
);
};

View File

@@ -1,12 +1,13 @@
// src/views/Community/components/StockDetailPanel/components/MiniTimelineChart.js
import React, { useState, useEffect, useMemo, useRef } from 'react';
import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import ReactECharts from 'echarts-for-react';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import {
fetchKlineData,
getCacheKey,
klineDataCache
klineDataCache,
batchPendingRequests
} from '../utils/klineDataCache';
/**
@@ -37,6 +38,25 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
};
}, []);
// 从缓存或API获取数据的函数
const loadData = useCallback(() => {
if (!stockCode || !mountedRef.current) return;
// 检查缓存
const cacheKey = getCacheKey(stockCode, stableEventTime);
const cachedData = klineDataCache.get(cacheKey);
// 如果有缓存数据,直接使用
if (cachedData && cachedData.length > 0) {
setData(cachedData);
setLoading(false);
loadedRef.current = true;
dataFetchedRef.current = true;
return true; // 表示数据已加载
}
return false; // 表示需要请求
}, [stockCode, stableEventTime]);
useEffect(() => {
if (!stockCode) {
setData([]);
@@ -50,19 +70,34 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
return;
}
// 检查缓存
const cacheKey = getCacheKey(stockCode, stableEventTime);
const cachedData = klineDataCache.get(cacheKey);
// 如果有缓存数据,直接使用
if (cachedData && cachedData.length > 0) {
setData(cachedData);
loadedRef.current = true;
dataFetchedRef.current = true;
// 尝试从缓存加载
if (loadData()) {
return;
}
// 标记正在请求
// 检查是否有正在进行的批量请求
const batchKey = `${stableEventTime || 'today'}|timeline`;
const pendingBatch = batchPendingRequests.get(batchKey);
if (pendingBatch) {
// 等待批量请求完成后再从缓存读取
setLoading(true);
dataFetchedRef.current = true;
pendingBatch.then(() => {
if (mountedRef.current) {
loadData();
setLoading(false);
}
}).catch(() => {
if (mountedRef.current) {
setData([]);
setLoading(false);
}
});
return;
}
// 没有批量请求,发起单独请求
dataFetchedRef.current = true;
setLoading(true);
@@ -82,7 +117,7 @@ const MiniTimelineChart = React.memo(function MiniTimelineChart({ stockCode, eve
loadedRef.current = true;
}
});
}, [stockCode, stableEventTime]); // 注意这里使用 stableEventTime
}, [stockCode, stableEventTime, loadData]); // 注意这里使用 stableEventTime
const chartOption = useMemo(() => {
const prices = data.map(item => item.close ?? item.price).filter(v => typeof v === 'number');

View File

@@ -1,9 +1,10 @@
// src/views/Community/components/StockDetailPanel/components/StockTable.js
import React, { useState, useCallback, useMemo } from 'react';
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
import { Table, Button } from 'antd';
import { StarFilled, StarOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import MiniTimelineChart from './MiniTimelineChart';
import { preloadBatchKlineData } from '../utils/klineDataCache';
import { logger } from '../../../../../utils/logger';
/**
@@ -28,12 +29,31 @@ const StockTable = ({
}) => {
// 展开/收缩的行
const [expandedRows, setExpandedRows] = useState(new Set());
const preloadedRef = useRef(false); // 标记是否已预加载
// 稳定的事件时间,避免重复渲染
const stableEventTime = useMemo(() => {
return eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
}, [eventTime]);
// 批量预加载K线数据
useEffect(() => {
if (stocks.length > 0 && !preloadedRef.current) {
const stockCodes = stocks.map(s => s.stock_code);
logger.debug('StockTable', '批量预加载K线数据', {
stockCount: stockCodes.length,
eventTime: stableEventTime
});
preloadBatchKlineData(stockCodes, stableEventTime, 'timeline');
preloadedRef.current = true;
}
}, [stocks, stableEventTime]);
// 当股票列表变化时重置预加载标记
useEffect(() => {
preloadedRef.current = false;
}, [stocks.length]);
// 切换行展开状态
const toggleRowExpand = useCallback((stockCode) => {
setExpandedRows(prev => {

View File

@@ -4,9 +4,10 @@ import { stockService } from '../../../../../services/eventService';
import { logger } from '../../../../../utils/logger';
// ================= 全局缓存和请求管理 =================
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}` -> data
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}` -> Promise
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}` -> timestamp
export const klineDataCache = new Map(); // 缓存K线数据: key = `${code}|${date}|${chartType}` -> data
export const pendingRequests = new Map(); // 正在进行的请求: key = `${code}|${date}|${chartType}` -> Promise
export const lastRequestTime = new Map(); // 最后请求时间: key = `${code}|${date}|${chartType}` -> timestamp
export const batchPendingRequests = new Map(); // 批量请求的 Promise: key = `${eventTime}|${chartType}` -> Promise
// 请求间隔限制(毫秒)
const REQUEST_INTERVAL = 30000; // 30秒内不重复请求同一只股票的数据
@@ -157,3 +158,131 @@ export const getCacheStats = () => {
cacheKeys: Array.from(klineDataCache.keys())
};
};
/**
* 批量获取多只股票的K线数据一次API请求
* @param {string[]} stockCodes - 股票代码数组
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
* @returns {Promise<Object>} 股票代码到K线数据的映射 { [stockCode]: data[] }
*/
export const fetchBatchKlineData = async (stockCodes, eventTime, chartType = 'timeline') => {
if (!stockCodes || stockCodes.length === 0) {
return {};
}
const normalizedEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : undefined;
const batchKey = `${normalizedEventTime || 'today'}|${chartType}`;
// 过滤出未缓存的股票
const uncachedCodes = stockCodes.filter(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
return !klineDataCache.has(cacheKey) || shouldRefreshData(cacheKey);
});
logger.debug('klineDataCache', '批量请求分析', {
totalCodes: stockCodes.length,
uncachedCodes: uncachedCodes.length,
cachedCodes: stockCodes.length - uncachedCodes.length
});
// 如果所有股票都有缓存,直接返回缓存数据
if (uncachedCodes.length === 0) {
const result = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
result[code] = klineDataCache.get(cacheKey) || [];
});
logger.debug('klineDataCache', '所有股票数据来自缓存', { stockCount: stockCodes.length });
return result;
}
// 检查是否有正在进行的批量请求
if (batchPendingRequests.has(batchKey)) {
logger.debug('klineDataCache', '等待进行中的批量请求', { batchKey });
return batchPendingRequests.get(batchKey);
}
// 发起批量请求
logger.debug('klineDataCache', '发起批量K线数据请求', {
batchKey,
stockCount: uncachedCodes.length,
chartType
});
const requestPromise = stockService
.getBatchKlineData(uncachedCodes, chartType, normalizedEventTime)
.then((response) => {
const batchData = response?.data || {};
const now = Date.now();
// 将批量数据存入缓存
Object.entries(batchData).forEach(([code, stockData]) => {
const data = Array.isArray(stockData?.data) ? stockData.data : [];
const cacheKey = getCacheKey(code, eventTime, chartType);
klineDataCache.set(cacheKey, data);
lastRequestTime.set(cacheKey, now);
});
// 对于请求中没有返回数据的股票,设置空数组
uncachedCodes.forEach(code => {
if (!batchData[code]) {
const cacheKey = getCacheKey(code, eventTime, chartType);
if (!klineDataCache.has(cacheKey)) {
klineDataCache.set(cacheKey, []);
lastRequestTime.set(cacheKey, now);
}
}
});
// 清除批量请求状态
batchPendingRequests.delete(batchKey);
logger.debug('klineDataCache', '批量K线数据请求完成', {
batchKey,
stockCount: Object.keys(batchData).length
});
// 返回所有请求股票的数据(包括之前缓存的)
const result = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
result[code] = klineDataCache.get(cacheKey) || [];
});
return result;
})
.catch((error) => {
logger.error('klineDataCache', 'fetchBatchKlineData', error, {
stockCount: uncachedCodes.length,
chartType
});
// 清除批量请求状态
batchPendingRequests.delete(batchKey);
// 返回已缓存的数据
const result = {};
stockCodes.forEach(code => {
const cacheKey = getCacheKey(code, eventTime, chartType);
result[code] = klineDataCache.get(cacheKey) || [];
});
return result;
});
// 保存批量请求
batchPendingRequests.set(batchKey, requestPromise);
return requestPromise;
};
/**
* 预加载多只股票的K线数据后台执行不阻塞UI
* @param {string[]} stockCodes - 股票代码数组
* @param {string} eventTime - 事件时间
* @param {string} chartType - 图表类型timeline/daily
*/
export const preloadBatchKlineData = (stockCodes, eventTime, chartType = 'timeline') => {
// 异步执行不返回Promise不阻塞调用方
fetchBatchKlineData(stockCodes, eventTime, chartType).catch(() => {
// 静默处理错误,预加载失败不影响用户体验
});
};