update pay ui
This commit is contained in:
120
app.py
120
app.py
@@ -7289,6 +7289,126 @@ def get_timeline_data(stock_code, event_datetime, stock_name):
|
||||
|
||||
|
||||
# ==================== 指数行情API(与股票逻辑一致,数据表为 index_minute) ====================
|
||||
|
||||
@app.route('/api/index/<index_code>/realtime')
|
||||
def get_index_realtime(index_code):
|
||||
"""
|
||||
获取指数实时行情(用于交易时间内的行情更新)
|
||||
从 index_minute 表获取最新的分钟数据
|
||||
返回: 最新价、涨跌幅、涨跌额、开盘价、最高价、最低价、昨收价
|
||||
"""
|
||||
client = get_clickhouse_client()
|
||||
today = date.today()
|
||||
|
||||
# 判断今天是否是交易日
|
||||
if today not in trading_days_set:
|
||||
# 非交易日,获取最近一个交易日的收盘数据
|
||||
target_date = get_trading_day_near_date(today)
|
||||
if not target_date:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No trading day found',
|
||||
'data': None
|
||||
})
|
||||
is_trading = False
|
||||
else:
|
||||
target_date = today
|
||||
# 判断是否在交易时间内
|
||||
now = datetime.now()
|
||||
current_minutes = now.hour * 60 + now.minute
|
||||
# 9:30-11:30 = 570-690, 13:00-15:00 = 780-900
|
||||
is_trading = (570 <= current_minutes <= 690) or (780 <= current_minutes <= 900)
|
||||
|
||||
try:
|
||||
# 获取当天/最近交易日的第一条数据(开盘价)和最后一条数据(最新价)
|
||||
# 同时获取最高价和最低价
|
||||
data = client.execute(
|
||||
"""
|
||||
SELECT
|
||||
min(open) as first_open,
|
||||
max(high) as day_high,
|
||||
min(low) as day_low,
|
||||
argMax(close, timestamp) as latest_close,
|
||||
argMax(timestamp, timestamp) as latest_time
|
||||
FROM index_minute
|
||||
WHERE code = %(code)s
|
||||
AND toDate(timestamp) = %(date)s
|
||||
""",
|
||||
{
|
||||
'code': index_code,
|
||||
'date': target_date,
|
||||
}
|
||||
)
|
||||
|
||||
if not data or not data[0] or data[0][3] is None:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': 'No data available',
|
||||
'data': None
|
||||
})
|
||||
|
||||
row = data[0]
|
||||
first_open = float(row[0]) if row[0] else None
|
||||
day_high = float(row[1]) if row[1] else None
|
||||
day_low = float(row[2]) if row[2] else None
|
||||
latest_close = float(row[3]) if row[3] else None
|
||||
latest_time = row[4]
|
||||
|
||||
# 获取昨收价(从 MySQL ea_exchangetrade 表)
|
||||
code_no_suffix = index_code.split('.')[0]
|
||||
prev_close = None
|
||||
|
||||
with engine.connect() as conn:
|
||||
# 获取前一个交易日的收盘价
|
||||
prev_result = conn.execute(text(
|
||||
"""
|
||||
SELECT F006N
|
||||
FROM ea_exchangetrade
|
||||
WHERE INDEXCODE = :code
|
||||
AND TRADEDATE < :today
|
||||
ORDER BY TRADEDATE DESC LIMIT 1
|
||||
"""
|
||||
), {
|
||||
'code': code_no_suffix,
|
||||
'today': datetime.combine(target_date, dt_time(0, 0, 0))
|
||||
}).fetchone()
|
||||
|
||||
if prev_result and prev_result[0]:
|
||||
prev_close = float(prev_result[0])
|
||||
|
||||
# 计算涨跌额和涨跌幅
|
||||
change_amount = None
|
||||
change_pct = None
|
||||
if latest_close is not None and prev_close is not None and prev_close > 0:
|
||||
change_amount = latest_close - prev_close
|
||||
change_pct = (change_amount / prev_close) * 100
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'code': index_code,
|
||||
'price': latest_close,
|
||||
'open': first_open,
|
||||
'high': day_high,
|
||||
'low': day_low,
|
||||
'prev_close': prev_close,
|
||||
'change': change_amount,
|
||||
'change_pct': change_pct,
|
||||
'update_time': latest_time.strftime('%H:%M:%S') if latest_time else None,
|
||||
'trade_date': target_date.strftime('%Y-%m-%d'),
|
||||
'is_trading': is_trading,
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'data': None
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route('/api/index/<index_code>/kline')
|
||||
def get_index_kline(index_code):
|
||||
chart_type = request.args.get('type', 'minute')
|
||||
|
||||
@@ -18,7 +18,6 @@ import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-
|
||||
import { FaCrown } from 'react-icons/fa';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import UserAvatar from './UserAvatar';
|
||||
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
|
||||
import { useSubscription } from '../../../../hooks/useSubscription';
|
||||
|
||||
/**
|
||||
@@ -38,12 +37,7 @@ const TabletUserMenu = memo(({
|
||||
followingEvents
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
subscriptionInfo,
|
||||
isSubscriptionModalOpen,
|
||||
openSubscriptionModal,
|
||||
closeSubscriptionModal
|
||||
} = useSubscription();
|
||||
const { subscriptionInfo } = useSubscription();
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
@@ -90,8 +84,8 @@ const TabletUserMenu = memo(({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 订阅管理 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={openSubscriptionModal}>
|
||||
{/* 订阅管理 - 移动端导航到订阅页面 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text>订阅管理</Text>
|
||||
<Badge colorScheme={getSubscriptionBadgeColor()}>
|
||||
@@ -149,14 +143,6 @@ const TabletUserMenu = memo(({
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
{/* 订阅弹窗 */}
|
||||
{isSubscriptionModalOpen && (
|
||||
<SubscriptionModal
|
||||
isOpen={isSubscriptionModalOpen}
|
||||
onClose={closeSubscriptionModal}
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
261
src/hooks/useIndexQuote.js
Normal file
261
src/hooks/useIndexQuote.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// src/hooks/useIndexQuote.js
|
||||
// 指数实时行情 Hook - 交易时间内每分钟自动更新
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// 交易日数据会从后端获取,这里只做时间判断
|
||||
const TRADING_SESSIONS = [
|
||||
{ start: { hour: 9, minute: 30 }, end: { hour: 11, minute: 30 } },
|
||||
{ start: { hour: 13, minute: 0 }, end: { hour: 15, minute: 0 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* 判断当前时间是否在交易时段内
|
||||
*/
|
||||
const isInTradingSession = () => {
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
return TRADING_SESSIONS.some(session => {
|
||||
const startMinutes = session.start.hour * 60 + session.start.minute;
|
||||
const endMinutes = session.end.hour * 60 + session.end.minute;
|
||||
return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指数实时行情
|
||||
*/
|
||||
const fetchIndexRealtime = async (indexCode) => {
|
||||
try {
|
||||
const response = await fetch(`/api/index/${indexCode}/realtime`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('useIndexQuote', 'fetchIndexRealtime error', { indexCode, error: error.message });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 指数实时行情 Hook
|
||||
*
|
||||
* @param {string} indexCode - 指数代码,如 '000001' (上证指数) 或 '399001' (深证成指)
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.refreshInterval - 刷新间隔(毫秒),默认 60000(1分钟)
|
||||
* @param {boolean} options.autoRefresh - 是否自动刷新,默认 true
|
||||
*
|
||||
* @returns {Object} { quote, loading, error, isTrading, refresh }
|
||||
*/
|
||||
export const useIndexQuote = (indexCode, options = {}) => {
|
||||
const {
|
||||
refreshInterval = 60000, // 默认1分钟
|
||||
autoRefresh = true,
|
||||
} = options;
|
||||
|
||||
const [quote, setQuote] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isTrading, setIsTrading] = useState(false);
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 加载数据
|
||||
const loadQuote = useCallback(async () => {
|
||||
if (!indexCode) return;
|
||||
|
||||
try {
|
||||
const data = await fetchIndexRealtime(indexCode);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (data) {
|
||||
setQuote(data);
|
||||
setIsTrading(data.is_trading);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('无法获取行情数据');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [indexCode]);
|
||||
|
||||
// 手动刷新
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadQuote();
|
||||
}, [loadQuote]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
loadQuote();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadQuote]);
|
||||
|
||||
// 自动刷新逻辑
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !indexCode) return;
|
||||
|
||||
// 清除旧的定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// 设置定时器,检查是否在交易时间内
|
||||
const checkAndRefresh = () => {
|
||||
const inSession = isInTradingSession();
|
||||
setIsTrading(inSession);
|
||||
|
||||
if (inSession) {
|
||||
loadQuote();
|
||||
}
|
||||
};
|
||||
|
||||
// 立即检查一次
|
||||
checkAndRefresh();
|
||||
|
||||
// 设置定时刷新
|
||||
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, indexCode, refreshInterval, loadQuote]);
|
||||
|
||||
return {
|
||||
quote,
|
||||
loading,
|
||||
error,
|
||||
isTrading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量获取多个指数的实时行情
|
||||
*
|
||||
* @param {string[]} indexCodes - 指数代码数组
|
||||
* @param {Object} options - 配置选项
|
||||
*/
|
||||
export const useMultiIndexQuotes = (indexCodes = [], options = {}) => {
|
||||
const {
|
||||
refreshInterval = 60000,
|
||||
autoRefresh = true,
|
||||
} = options;
|
||||
|
||||
const [quotes, setQuotes] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isTrading, setIsTrading] = useState(false);
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 批量加载数据
|
||||
const loadQuotes = useCallback(async () => {
|
||||
if (!indexCodes || indexCodes.length === 0) return;
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
indexCodes.map(code => fetchIndexRealtime(code))
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const newQuotes = {};
|
||||
let hasTrading = false;
|
||||
|
||||
results.forEach((data, idx) => {
|
||||
if (data) {
|
||||
newQuotes[indexCodes[idx]] = data;
|
||||
if (data.is_trading) hasTrading = true;
|
||||
}
|
||||
});
|
||||
|
||||
setQuotes(newQuotes);
|
||||
setIsTrading(hasTrading);
|
||||
} catch (err) {
|
||||
logger.error('useMultiIndexQuotes', 'loadQuotes error', err);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [indexCodes]);
|
||||
|
||||
// 手动刷新
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadQuotes();
|
||||
}, [loadQuotes]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
loadQuotes();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadQuotes]);
|
||||
|
||||
// 自动刷新逻辑
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || indexCodes.length === 0) return;
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
const checkAndRefresh = () => {
|
||||
const inSession = isInTradingSession();
|
||||
setIsTrading(inSession);
|
||||
|
||||
if (inSession) {
|
||||
loadQuotes();
|
||||
}
|
||||
};
|
||||
|
||||
checkAndRefresh();
|
||||
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, indexCodes, refreshInterval, loadQuotes]);
|
||||
|
||||
return {
|
||||
quotes,
|
||||
loading,
|
||||
isTrading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
export default useIndexQuote;
|
||||
@@ -1,5 +1,6 @@
|
||||
// src/views/Community/components/HeroPanel.js
|
||||
// 顶部说明面板组件:事件中心 + 沪深指数K线图 + 热门概念3D动画
|
||||
// 交易时间内自动更新指数行情(每分钟一次)
|
||||
|
||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
@@ -22,10 +23,12 @@ import {
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
Tooltip,
|
||||
} from '@chakra-ui/react';
|
||||
import { AlertCircle, Clock, TrendingUp, Info } from 'lucide-react';
|
||||
import { AlertCircle, Clock, TrendingUp, Info, RefreshCw } from 'lucide-react';
|
||||
import ReactECharts from 'echarts-for-react';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { useIndexQuote } from '../../../hooks/useIndexQuote';
|
||||
|
||||
// 定义动画
|
||||
const animations = `
|
||||
@@ -104,6 +107,7 @@ const isInTradingTime = () => {
|
||||
|
||||
/**
|
||||
* 精美K线指数卡片 - 类似 KLineChartModal 风格
|
||||
* 交易时间内自动更新实时行情(每分钟一次)
|
||||
*/
|
||||
const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
const [chartData, setChartData] = useState(null);
|
||||
@@ -113,38 +117,66 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
const upColor = '#ef5350'; // 涨 - 红色
|
||||
const downColor = '#26a69a'; // 跌 - 绿色
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
// 使用实时行情 Hook - 交易时间内每分钟自动更新
|
||||
const { quote, isTrading, refresh: refreshQuote } = useIndexQuote(indexCode, {
|
||||
refreshInterval: 60000, // 1分钟
|
||||
autoRefresh: true,
|
||||
});
|
||||
|
||||
// 加载日K线图数据
|
||||
const loadChartData = useCallback(async () => {
|
||||
const data = await fetchIndexKline(indexCode);
|
||||
if (data?.data?.length > 0) {
|
||||
const latest = data.data[data.data.length - 1];
|
||||
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
|
||||
const changeAmount = latest.close - prevClose;
|
||||
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
|
||||
|
||||
setLatestData({
|
||||
close: latest.close,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
changeAmount: changeAmount,
|
||||
changePct: changePct,
|
||||
isPositive: changeAmount >= 0
|
||||
});
|
||||
|
||||
const recentData = data.data.slice(-60); // 增加到60天
|
||||
const recentData = data.data.slice(-60); // 最近60天
|
||||
setChartData({
|
||||
dates: recentData.map(item => item.time),
|
||||
klineData: recentData.map(item => [item.open, item.close, item.low, item.high]),
|
||||
volumes: recentData.map(item => item.volume || 0),
|
||||
rawData: recentData
|
||||
});
|
||||
|
||||
// 如果没有实时行情,使用日线数据的最新值
|
||||
if (!quote) {
|
||||
const latest = data.data[data.data.length - 1];
|
||||
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
|
||||
const changeAmount = latest.close - prevClose;
|
||||
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
|
||||
|
||||
setLatestData({
|
||||
close: latest.close,
|
||||
open: latest.open,
|
||||
high: latest.high,
|
||||
low: latest.low,
|
||||
changeAmount: changeAmount,
|
||||
changePct: changePct,
|
||||
isPositive: changeAmount >= 0
|
||||
});
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, [indexCode]);
|
||||
}, [indexCode, quote]);
|
||||
|
||||
// 初始加载日K数据
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
loadChartData();
|
||||
}, [loadChartData]);
|
||||
|
||||
// 当实时行情更新时,更新 latestData
|
||||
useEffect(() => {
|
||||
if (quote) {
|
||||
setLatestData({
|
||||
close: quote.price,
|
||||
open: quote.open,
|
||||
high: quote.high,
|
||||
low: quote.low,
|
||||
changeAmount: quote.change,
|
||||
changePct: quote.change_pct,
|
||||
isPositive: quote.change >= 0,
|
||||
updateTime: quote.update_time,
|
||||
isRealtime: true,
|
||||
});
|
||||
}
|
||||
}, [quote]);
|
||||
|
||||
const chartOption = useMemo(() => {
|
||||
if (!chartData) return {};
|
||||
@@ -306,6 +338,30 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
<Text fontSize="sm" color="whiteAlpha.800" fontWeight="semibold">
|
||||
{indexName}
|
||||
</Text>
|
||||
{/* 实时状态指示 */}
|
||||
{isTrading && latestData?.isRealtime && (
|
||||
<Tooltip label="实时行情,每分钟更新" placement="top">
|
||||
<HStack
|
||||
spacing={1}
|
||||
px={1.5}
|
||||
py={0.5}
|
||||
bg="rgba(0,218,60,0.1)"
|
||||
borderRadius="full"
|
||||
border="1px solid rgba(0,218,60,0.3)"
|
||||
>
|
||||
<Box
|
||||
w="5px"
|
||||
h="5px"
|
||||
borderRadius="full"
|
||||
bg="#00da3c"
|
||||
animation="pulse 1.5s infinite"
|
||||
/>
|
||||
<Text fontSize="9px" color="#00da3c" fontWeight="bold">
|
||||
实时
|
||||
</Text>
|
||||
</HStack>
|
||||
</Tooltip>
|
||||
)}
|
||||
</HStack>
|
||||
<HStack spacing={3}>
|
||||
<Text fontSize="lg" fontWeight="bold" color="white" fontFamily="monospace">
|
||||
@@ -338,16 +394,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
opts={{ renderer: 'canvas' }}
|
||||
/>
|
||||
{/* 底部提示 */}
|
||||
<Text
|
||||
{/* 底部提示 - 显示更新时间 */}
|
||||
<HStack
|
||||
position="absolute"
|
||||
bottom={0}
|
||||
right={1}
|
||||
fontSize="9px"
|
||||
color="whiteAlpha.300"
|
||||
spacing={2}
|
||||
>
|
||||
滚轮缩放 · 拖动查看
|
||||
</Text>
|
||||
{latestData?.updateTime && (
|
||||
<Text fontSize="9px" color="whiteAlpha.400">
|
||||
{latestData.updateTime}
|
||||
</Text>
|
||||
)}
|
||||
<Text fontSize="9px" color="whiteAlpha.300">
|
||||
滚轮缩放 · 拖动查看
|
||||
</Text>
|
||||
</HStack>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user