From 7708cb1a69b1149c3af863d075ae47a34c6128a7 Mon Sep 17 00:00:00 2001 From: zzlgreat Date: Tue, 2 Dec 2025 10:33:55 +0800 Subject: [PATCH] update pay ui --- app.py | 120 ++++++++ .../components/UserMenu/TabletUserMenu.js | 20 +- src/hooks/useIndexQuote.js | 261 ++++++++++++++++++ src/views/Community/components/HeroPanel.js | 116 ++++++-- 4 files changed, 473 insertions(+), 44 deletions(-) create mode 100644 src/hooks/useIndexQuote.js diff --git a/app.py b/app.py index 954b5c66..4af6c0fb 100755 --- a/app.py +++ b/app.py @@ -7289,6 +7289,126 @@ def get_timeline_data(stock_code, event_datetime, stock_name): # ==================== 指数行情API(与股票逻辑一致,数据表为 index_minute) ==================== + +@app.route('/api/index//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//kline') def get_index_kline(index_code): chart_type = request.args.get('type', 'minute') diff --git a/src/components/Navbars/components/UserMenu/TabletUserMenu.js b/src/components/Navbars/components/UserMenu/TabletUserMenu.js index 9857138f..5ca9a8b3 100644 --- a/src/components/Navbars/components/UserMenu/TabletUserMenu.js +++ b/src/components/Navbars/components/UserMenu/TabletUserMenu.js @@ -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(({ )} - {/* 订阅管理 */} - } onClick={openSubscriptionModal}> + {/* 订阅管理 - 移动端导航到订阅页面 */} + } onClick={() => navigate('/home/pages/account/subscription')}> 订阅管理 @@ -149,14 +143,6 @@ const TabletUserMenu = memo(({ - {/* 订阅弹窗 */} - {isSubscriptionModalOpen && ( - - )} ); }); diff --git a/src/hooks/useIndexQuote.js b/src/hooks/useIndexQuote.js new file mode 100644 index 00000000..3a4cfe7a --- /dev/null +++ b/src/hooks/useIndexQuote.js @@ -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; diff --git a/src/views/Community/components/HeroPanel.js b/src/views/Community/components/HeroPanel.js index 98803ca1..c30352c4 100644 --- a/src/views/Community/components/HeroPanel.js +++ b/src/views/Community/components/HeroPanel.js @@ -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 }) => { {indexName} + {/* 实时状态指示 */} + {isTrading && latestData?.isRealtime && ( + + + + + 实时 + + + + )} @@ -338,16 +394,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => { style={{ height: '100%', width: '100%' }} opts={{ renderer: 'canvas' }} /> - {/* 底部提示 */} - - 滚轮缩放 · 拖动查看 - + {latestData?.updateTime && ( + + {latestData.updateTime} + + )} + + 滚轮缩放 · 拖动查看 + + );