""" Dash Web应用:提供交互式缠论分析界面 """ import dash from dash import dcc, html, Input, Output, State, callback_context import plotly.graph_objects as go import pandas as pd import numpy as np from datetime import datetime, timedelta import logging from data.data_fetcher import DataFetcher from data.data_processor import DataProcessor from core.chan_analyzer import ChanAnalyzer from .visualization import ChanVisualizer logger = logging.getLogger(__name__) def create_app(): """创建Dash应用""" app = dash.Dash(__name__, external_stylesheets=[ 'https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css' ]) # 添加自定义CSS样式 app.index_string = ''' {%metas%} {%title%} {%favicon%} {%css%} {%app_entry%} ''' # 初始化组件 data_fetcher = DataFetcher() data_processor = DataProcessor() visualizer = ChanVisualizer() # 应用布局 app.layout = html.Div([ # 标题 html.H1("缠论分析系统", className="text-center mb-2"), # 控制面板 html.Div([ html.Div([ html.Label("交易对:", className="small"), dcc.Dropdown( id='symbol-dropdown', options=[ {'label': 'BTC/USDT', 'value': 'BTC/USDT'}, {'label': 'ETH/USDT', 'value': 'ETH/USDT'}, {'label': 'BNB/USDT', 'value': 'BNB/USDT'}, {'label': 'ADA/USDT', 'value': 'ADA/USDT'}, {'label': 'SOL/USDT', 'value': 'SOL/USDT'} ], value='BTC/USDT', className="mb-1" ) ], className="col-md-2"), html.Div([ html.Label("时间周期:", className="small"), dcc.Dropdown( id='timeframe-dropdown', options=[ {'label': '1分钟', 'value': '1m'}, {'label': '5分钟', 'value': '5m'}, {'label': '15分钟', 'value': '15m'}, {'label': '30分钟', 'value': '30m'}, {'label': '1小时', 'value': '1h'}, {'label': '4小时', 'value': '4h'}, {'label': '1天', 'value': '1d'} ], value='5m', className="form-select mb-1" ) ], className="col-md-2"), html.Div([ html.Label("数据数量:", className="small"), dcc.Slider( id='limit-slider', min=100, max=1000, step=50, value=100, marks={i: str(i) for i in range(100, 1001, 300)}, className="mb-1" ) ], className="col-md-2"), html.Div([ html.Label("基础分型强度:", className="small"), dcc.Slider( id='fractal-strength-slider', min=1, max=5, step=1, value=5, marks={i: str(i) for i in range(1, 6)}, className="mb-1" ) ], className="col-md-2"), html.Div([ html.Label("增强强度过滤:", className="small"), dcc.Slider( id='enhanced-strength-filter', min=0, max=100, step=10, value=0, marks={i: str(i) for i in range(0, 101, 50)}, className="mb-1" ) ], className="col-md-2"), html.Div([ html.Label("显示级别:", className="small"), dcc.Dropdown( id='display-level-dropdown', options=[ {'label': '全部分型', 'value': 'all'}, {'label': '仅强势(≥70分)', 'value': 'strong'}, {'label': '中等以上(≥40分)', 'value': 'medium_plus'}, {'label': '自定义过滤', 'value': 'custom'} ], value='all', className="form-select mb-1" ) ], className="col-md-2") ], className="row mb-2"), # 自动刷新控制行 html.Div([ html.Div([ html.Label("自动刷新:", className="form-label small"), dcc.Checklist( id='auto-refresh-toggle', options=[{'label': '启用', 'value': 'enabled'}], value=[], className="form-check small" ) ], className="col-md-2"), html.Div([ html.Label("刷新间隔:", className="form-label small"), dcc.Dropdown( id='refresh-interval-dropdown', options=[ {'label': '5秒', 'value': 5000}, {'label': '10秒', 'value': 10000}, {'label': '15秒', 'value': 15000}, {'label': '30秒', 'value': 30000}, {'label': '1分钟', 'value': 60000}, {'label': '5分钟', 'value': 300000} ], value=15000, # 默认15秒 className="form-select small", disabled=True # 初始状态禁用 ) ], className="col-md-2"), html.Div([ html.Label("状态:", className="form-label small"), html.Div(id='refresh-status', children="未启用", className="text-muted small") ], className="col-md-4"), html.Div([ html.Label("下次刷新:", className="form-label small"), html.Div(id='next-refresh-time', children="--", className="text-muted small") ], className="col-md-4") ], className="row mb-2"), # 按钮组 html.Div([ html.Button("获取数据并分析", id="analyze-btn", className="btn btn-primary btn-sm me-2"), html.Button("刷新数据", id="refresh-btn", className="btn btn-secondary btn-sm me-2"), html.Button("导出结果", id="export-btn", className="btn btn-success btn-sm"), ], className="text-center mb-2"), # 加载状态 dcc.Loading( id="loading", children=[ # 主图表 html.Div([ dcc.Graph(id='main-chart', style={'height': '600px'}) ], className="mb-2"), # 信息面板 - 使用三栏布局 html.Div([ # 统计信息 html.Div([ html.H5("分析统计", className="mb-1"), html.Div(id='statistics-content') ], className="col-md-4 mb-2"), # 市场结构 html.Div([ html.H5("市场结构", className="mb-1"), html.Div(id='market-structure-content') ], className="col-md-4 mb-2"), # 最新信号 html.Div([ html.H5("最新信号", className="mb-1"), html.Div(id='latest-signals-content') ], className="col-md-4 mb-2") ], className="row") ] ), # 存储数据和定时器组件 dcc.Store(id='analysis-data'), dcc.Store(id='market-structure-data'), dcc.Store(id='auto-refresh-settings'), dcc.Store(id='refresh-start-time'), # 存储刷新开始时间 dcc.Interval( id='auto-refresh-interval', interval=15000, # 默认15秒 n_intervals=0, disabled=True # 初始状态禁用 ), dcc.Interval( id='countdown-timer', interval=1000, # 每秒更新一次倒计时 n_intervals=0, disabled=False # 始终启用 ) ], className="container-fluid p-2") # 回调函数 @app.callback( [Output('analysis-data', 'data'), Output('market-structure-data', 'data')], [Input('analyze-btn', 'n_clicks'), Input('refresh-btn', 'n_clicks'), Input('auto-refresh-interval', 'n_intervals')], [State('symbol-dropdown', 'value'), State('timeframe-dropdown', 'value'), State('limit-slider', 'value'), State('fractal-strength-slider', 'value'), State('enhanced-strength-filter', 'value'), State('display-level-dropdown', 'value'), State('auto-refresh-settings', 'data')] ) def analyze_data_with_auto_refresh(analyze_clicks, refresh_clicks, auto_refresh_intervals, symbol, timeframe, limit, fractal_strength, enhanced_strength_filter, display_level, auto_refresh_settings): """带自动刷新功能的数据分析""" ctx = callback_context if not ctx.triggered: return None, None trigger_id = ctx.triggered[0]['prop_id'].split('.')[0] # 检查是否是自动刷新触发,且自动刷新已启用 if trigger_id == 'auto-refresh-interval': if not auto_refresh_settings or not auto_refresh_settings.get('enabled'): return None, None # 只有当auto_refresh_intervals > 0时才进行自动刷新 if auto_refresh_intervals == 0: return None, None elif not analyze_clicks and not refresh_clicks: return None, None try: # 获取数据 fetcher = DataFetcher() df = fetcher.fetch_klines(symbol=symbol, timeframe=timeframe, limit=limit) if df.empty: return None, None # 进行缠论分析 - 使用正确的初始化方式 analyzer = ChanAnalyzer(df) # 传入原始DataFrame result = analyzer.run_full_analysis(fractal_strength=fractal_strength) # 重置索引,确保timestamp列存在,避免列名重复 df_reset = df.reset_index() if 'timestamp' in df_reset.columns: df_reset = df_reset.drop(columns=['timestamp']) # 删除可能重复的timestamp列 df_reset.rename(columns={'datetime': 'timestamp'}, inplace=True) # 获取缠论分析的详细结果 fractals_data = [] if hasattr(analyzer, 'fractals') and analyzer.fractals: for f in analyzer.fractals: fractals_data.append({ 'timestamp': f.timestamp.isoformat() if hasattr(f.timestamp, 'isoformat') else str(f.timestamp), 'price': float(f.price), 'type': f.fractal_type, 'strength': f.strength, 'enhanced_strength': getattr(f, 'enhanced_strength', 0), 'price_dominance': getattr(f, 'price_dominance', 0), 'volume_strength': getattr(f, 'volume_strength', 0), 'trend_position': getattr(f, 'trend_position', 0) }) strokes_data = [] if hasattr(analyzer, 'strokes') and analyzer.strokes: for s in analyzer.strokes: strokes_data.append({ 'start_time': s.start_fractal.timestamp.isoformat() if hasattr(s.start_fractal.timestamp, 'isoformat') else str(s.start_fractal.timestamp), 'end_time': s.end_fractal.timestamp.isoformat() if hasattr(s.end_fractal.timestamp, 'isoformat') else str(s.end_fractal.timestamp), 'start_price': float(s.start_fractal.price), 'end_price': float(s.end_fractal.price), 'direction': s.direction }) central_banks_data = [] if hasattr(analyzer, 'central_banks') and analyzer.central_banks: for cb in analyzer.central_banks: central_banks_data.append({ 'start_time': cb.start_time.isoformat() if hasattr(cb.start_time, 'isoformat') else str(cb.start_time), 'end_time': cb.end_time.isoformat() if hasattr(cb.end_time, 'isoformat') else str(cb.end_time), 'high_price': float(cb.high_price), 'low_price': float(cb.low_price), 'center_price': float(cb.center_price) }) trading_points_data = [] if hasattr(analyzer, 'trading_points') and analyzer.trading_points: for tp in analyzer.trading_points: trading_points_data.append({ 'timestamp': tp.timestamp.isoformat() if hasattr(tp.timestamp, 'isoformat') else str(tp.timestamp), 'price': float(tp.price), 'signal_type': tp.signal_type, 'point_class': tp.point_class, 'description': tp.description }) # 序列化分析结果为简单的字典格式 analysis_data = { 'df': df_reset.to_dict('records'), 'fractals': fractals_data, 'strokes': strokes_data, 'central_banks': central_banks_data, 'trading_points': trading_points_data, 'processed_klines_count': result['data_info']['processed_klines'], 'fractals_count': result['fractal_info']['total'], 'strokes_count': result['stroke_info']['total'], 'segments_count': result['segment_info']['total'], 'central_banks_count': result['central_bank_info']['total'], 'trading_points_count': result['trading_signal_info']['total'], 'symbol': symbol, 'timeframe': timeframe, 'last_update': datetime.now().strftime("%Y-%m-%d %H:%M:%S"), # 添加更新时间 'update_type': 'auto' if trigger_id == 'auto-refresh-interval' else 'manual' # 标记更新类型 } # 市场结构数据 market_data = { 'latest_price': float(df['close'].iloc[-1]) if not df.empty else 0, 'price_change': float(df['close'].iloc[-1] - df['close'].iloc[0]) if len(df) > 1 else 0, 'volume_avg': float(df['volume'].mean()) if not df.empty else 0, 'high_24h': float(df['high'].max()) if not df.empty else 0, 'low_24h': float(df['low'].min()) if not df.empty else 0 } return analysis_data, market_data except Exception as e: print(f"分析数据时出错: {str(e)}") return None, None @app.callback( Output('main-chart', 'figure'), [Input('analysis-data', 'data'), Input('enhanced-strength-filter', 'value'), Input('display-level-dropdown', 'value')] ) def update_main_chart(analysis_data, enhanced_strength_filter, display_level): if not analysis_data or not analysis_data.get('df'): return go.Figure() # 从数据创建基本K线图 df_records = analysis_data['df'] df = pd.DataFrame(df_records) df['timestamp'] = pd.to_datetime(df['timestamp']) fig = go.Figure() # 添加K线图 fig.add_trace(go.Candlestick( x=df['timestamp'], open=df['open'], high=df['high'], low=df['low'], close=df['close'], name="K线" )) # 添加分型点(带过滤功能) if analysis_data.get('fractals'): fractals = analysis_data['fractals'] # 根据显示级别和增强强度过滤分型 filtered_fractals = [] for f in fractals: enhanced_strength = f.get('enhanced_strength', 0) # 应用显示级别过滤 if display_level == 'strong' and enhanced_strength < 70: continue elif display_level == 'medium_plus' and enhanced_strength < 40: continue elif display_level == 'custom' and enhanced_strength < enhanced_strength_filter: continue filtered_fractals.append(f) top_fractals = [f for f in filtered_fractals if f['type'] == 'top'] bottom_fractals = [f for f in filtered_fractals if f['type'] == 'bottom'] if top_fractals: # 根据增强强度确定大小和颜色 sizes = [max(8, min(f.get('enhanced_strength', 30) / 5, 20)) for f in top_fractals] colors = [f'rgba(255, {max(0, 255 - int(f.get("enhanced_strength", 30) * 2))}, 0, 0.8)' for f in top_fractals] fig.add_trace(go.Scatter( x=[pd.to_datetime(f['timestamp']) for f in top_fractals], y=[f['price'] for f in top_fractals], mode='markers', marker=dict( symbol='triangle-down', size=sizes, color=colors, line=dict(color='darkred', width=1) ), name=f'顶分型({len(top_fractals)}个)', hovertemplate=('顶分型
' '时间: %{x}
' '价格: %{y:.2f}
' '基础强度: %{customdata[0]}
' '增强强度: %{customdata[1]:.1f}
' '价格优势: %{customdata[2]:.1f}
' '成交量强度: %{customdata[3]:.1f}
' '趋势位置: %{customdata[4]:.1f}'), customdata=[[f['strength'], f.get('enhanced_strength', 0), f.get('price_dominance', 0), f.get('volume_strength', 0), f.get('trend_position', 0)] for f in top_fractals] )) if bottom_fractals: # 根据增强强度确定大小和颜色 sizes = [max(8, min(f.get('enhanced_strength', 30) / 5, 20)) for f in bottom_fractals] colors = [f'rgba(0, {max(100, 255 - int(f.get("enhanced_strength", 30) * 1.5))}, 0, 0.8)' for f in bottom_fractals] fig.add_trace(go.Scatter( x=[pd.to_datetime(f['timestamp']) for f in bottom_fractals], y=[f['price'] for f in bottom_fractals], mode='markers', marker=dict( symbol='triangle-up', size=sizes, color=colors, line=dict(color='darkgreen', width=1) ), name=f'底分型({len(bottom_fractals)}个)', hovertemplate=('底分型
' '时间: %{x}
' '价格: %{y:.2f}
' '基础强度: %{customdata[0]}
' '增强强度: %{customdata[1]:.1f}
' '价格优势: %{customdata[2]:.1f}
' '成交量强度: %{customdata[3]:.1f}
' '趋势位置: %{customdata[4]:.1f}'), customdata=[[f['strength'], f.get('enhanced_strength', 0), f.get('price_dominance', 0), f.get('volume_strength', 0), f.get('trend_position', 0)] for f in bottom_fractals] )) # 添加笔 if analysis_data.get('strokes'): strokes = analysis_data['strokes'] for i, stroke in enumerate(strokes): color = 'blue' if stroke['direction'] == 1 else 'purple' fig.add_trace(go.Scatter( x=[pd.to_datetime(stroke['start_time']), pd.to_datetime(stroke['end_time'])], y=[stroke['start_price'], stroke['end_price']], mode='lines', line=dict(color=color, width=2), name='笔' if i == 0 else None, showlegend=(i == 0), hovertemplate=f'笔 {"↗" if stroke["direction"] == 1 else "↘"}
起点: %{{x[0]}}
终点: %{{x[1]}}
价格变化: {stroke["end_price"] - stroke["start_price"]:.2f}' )) # 添加中枢 if analysis_data.get('central_banks'): central_banks = analysis_data['central_banks'] for i, cb in enumerate(central_banks): # 中枢区域用矩形表示 fig.add_shape( type="rect", x0=pd.to_datetime(cb['start_time']), x1=pd.to_datetime(cb['end_time']), y0=cb['low_price'], y1=cb['high_price'], fillcolor="yellow", opacity=0.3, line=dict(color="orange", width=2), layer="below" ) # 中枢中轴线 fig.add_trace(go.Scatter( x=[pd.to_datetime(cb['start_time']), pd.to_datetime(cb['end_time'])], y=[cb['center_price'], cb['center_price']], mode='lines', line=dict(color='orange', width=2, dash='dash'), name='中枢' if i == 0 else None, showlegend=(i == 0), hovertemplate=f'中枢
高点: {cb["high_price"]:.2f}
低点: {cb["low_price"]:.2f}
中轴: {cb["center_price"]:.2f}' )) # 添加买卖点 if analysis_data.get('trading_points'): trading_points = analysis_data['trading_points'] buy_points = [tp for tp in trading_points if tp['signal_type'] == 'buy'] sell_points = [tp for tp in trading_points if tp['signal_type'] == 'sell'] if buy_points: colors = {'first': 'lime', 'second': 'lightgreen', 'third': 'lightblue'} for point_class in ['first', 'second', 'third']: class_points = [tp for tp in buy_points if tp['point_class'] == point_class] if class_points: fig.add_trace(go.Scatter( x=[pd.to_datetime(tp['timestamp']) for tp in class_points], y=[tp['price'] for tp in class_points], mode='markers', marker=dict( symbol='arrow-up', size=15, color=colors.get(point_class, 'lime'), line=dict(color='darkgreen', width=2) ), name=f'{point_class[0].upper() + point_class[1:]}类买点', hovertemplate='%{fullData.name}
时间: %{x}
价格: %{y:.2f}
描述: %{customdata}', customdata=[tp['description'] for tp in class_points] )) if sell_points: colors = {'first': 'red', 'second': 'lightcoral', 'third': 'pink'} for point_class in ['first', 'second', 'third']: class_points = [tp for tp in sell_points if tp['point_class'] == point_class] if class_points: fig.add_trace(go.Scatter( x=[pd.to_datetime(tp['timestamp']) for tp in class_points], y=[tp['price'] for tp in class_points], mode='markers', marker=dict( symbol='arrow-down', size=15, color=colors.get(point_class, 'red'), line=dict(color='darkred', width=2) ), name=f'{point_class[0].upper() + point_class[1:]}类卖点', hovertemplate='%{fullData.name}
时间: %{x}
价格: %{y:.2f}
描述: %{customdata}', customdata=[tp['description'] for tp in class_points] )) fig.update_layout( title=f"{analysis_data['symbol']} {analysis_data['timeframe']} 缠论分析图", xaxis_title="时间", yaxis_title="价格", height=550, xaxis_rangeslider_visible=False, hovermode='x unified', margin=dict(l=50, r=50, t=50, b=50) ) return fig @app.callback( Output('statistics-content', 'children'), [Input('analysis-data', 'data'), Input('enhanced-strength-filter', 'value'), Input('display-level-dropdown', 'value')] ) def update_statistics(analysis_data, enhanced_strength_filter, display_level): if not analysis_data: return "暂无数据" # 基础统计 basic_stats = html.Div([ html.P(f"交易对: {analysis_data.get('symbol', 'N/A')}", className="mb-1 small"), html.P(f"时间周期: {analysis_data.get('timeframe', 'N/A')}", className="mb-1 small"), html.P(f"K线数量: {analysis_data.get('processed_klines_count', 0)}", className="mb-1 small"), html.P(f"笔: {analysis_data.get('strokes_count', 0)} | 线段: {analysis_data.get('segments_count', 0)}", className="mb-1 small"), html.P(f"中枢: {analysis_data.get('central_banks_count', 0)} | 买卖点: {analysis_data.get('trading_points_count', 0)}", className="mb-1 small"), html.Hr(className="my-1"), html.P(f"更新: {analysis_data.get('last_update', 'N/A')}", className="text-info small mb-1"), html.P(f"方式: {'自动' if analysis_data.get('update_type') == 'auto' else '手动'}", className="text-muted small mb-0") ]) # 分型强度统计 fractals = analysis_data.get('fractals', []) if fractals: # 计算强度分布 strong_fractals = [f for f in fractals if f.get('enhanced_strength', 0) >= 70] medium_fractals = [f for f in fractals if 40 <= f.get('enhanced_strength', 0) < 70] weak_fractals = [f for f in fractals if f.get('enhanced_strength', 0) < 40] # 根据当前过滤条件计算显示的分型 filtered_fractals = [] for f in fractals: enhanced_strength = f.get('enhanced_strength', 0) if display_level == 'strong' and enhanced_strength < 70: continue elif display_level == 'medium_plus' and enhanced_strength < 40: continue elif display_level == 'custom' and enhanced_strength < enhanced_strength_filter: continue filtered_fractals.append(f) # 平均强度 avg_enhanced = sum(f.get('enhanced_strength', 0) for f in fractals) / len(fractals) if fractals else 0 fractal_stats = html.Div([ html.Hr(className="my-1"), html.P(f"🔥 分型分析", className="fw-bold small mb-1"), html.P(f"总数: {len(fractals)} | 强势: {len(strong_fractals)} | 中等: {len(medium_fractals)}", className="mb-1 small"), html.P(f"当前显示: {len(filtered_fractals)} 个", className="mb-1 small"), html.P(f"平均强度: {avg_enhanced:.1f}分", className="mb-0 small text-muted") ]) return html.Div([basic_stats, fractal_stats]) else: return basic_stats @app.callback( Output('market-structure-content', 'children'), [Input('market-structure-data', 'data')] ) def update_market_structure(market_data): if not market_data: return "暂无数据" latest_price = market_data.get('latest_price', 0) price_change = market_data.get('price_change', 0) change_percent = (price_change / (latest_price - price_change)) * 100 if (latest_price - price_change) != 0 else 0 color = "text-success" if price_change >= 0 else "text-danger" return html.Div([ html.P(f"当前价格: ${latest_price:.2f}", className="mb-1 small"), html.P(f"变化: ${price_change:.2f} ({change_percent:+.2f}%)", className=f"mb-1 small {color}"), html.P(f"24h最高: ${market_data.get('high_24h', 0):.2f}", className="mb-1 small"), html.P(f"24h最低: ${market_data.get('low_24h', 0):.2f}", className="mb-1 small"), html.P(f"平均量: {market_data.get('volume_avg', 0):.0f}", className="mb-0 small text-muted") ]) @app.callback( Output('latest-signals-content', 'children'), [Input('analysis-data', 'data')] ) def update_latest_signals(analysis_data): if not analysis_data: return "暂无数据" trading_points_count = analysis_data.get('trading_points_count', 0) fractals_count = analysis_data.get('fractals_count', 0) return html.Div([ html.P(f"买卖点: {trading_points_count} 个", className="mb-1 small"), html.P(f"分型: {fractals_count} 个", className="mb-1 small"), html.P("详情见图表标记", className="mb-0 small text-muted") ]) # 自动刷新功能回调函数 @app.callback( [Output('refresh-interval-dropdown', 'disabled'), Output('auto-refresh-interval', 'disabled'), Output('auto-refresh-interval', 'interval'), Output('refresh-status', 'children'), Output('auto-refresh-settings', 'data'), Output('refresh-start-time', 'data')], [Input('auto-refresh-toggle', 'value'), Input('refresh-interval-dropdown', 'value')], prevent_initial_call=True ) def control_auto_refresh(auto_refresh_enabled, refresh_interval): """控制自动刷新功能""" enabled = 'enabled' in (auto_refresh_enabled or []) if enabled: status = f"已启用 - 每{refresh_interval/1000:.0f}秒刷新" dropdown_disabled = False interval_disabled = False else: status = "未启用" dropdown_disabled = True interval_disabled = True refresh_interval = 15000 # 保持默认值 settings = { 'enabled': enabled, 'interval': refresh_interval } # 记录当前时间作为刷新开始时间 from datetime import datetime start_time = datetime.now().timestamp() if enabled else None return dropdown_disabled, interval_disabled, refresh_interval, status, settings, start_time @app.callback( Output('next-refresh-time', 'children'), [Input('countdown-timer', 'n_intervals'), Input('auto-refresh-settings', 'data'), Input('refresh-start-time', 'data'), Input('auto-refresh-interval', 'n_intervals')], prevent_initial_call=True ) def update_countdown(timer_intervals, settings, start_time, auto_refresh_intervals): """更新倒计时显示""" if not settings or not settings.get('enabled') or start_time is None: return "--" from datetime import datetime import math # 获取刷新间隔(秒) interval_seconds = settings.get('interval', 15000) / 1000 # 计算自启用或上次刷新以来的时间 current_time = datetime.now().timestamp() # 计算已经过去的时间 elapsed_time = current_time - start_time # 考虑自动刷新的次数,重新计算开始时间 if auto_refresh_intervals > 0: # 调整开始时间,考虑已经发生的自动刷新 adjusted_start_time = start_time + (auto_refresh_intervals * interval_seconds) elapsed_time = current_time - adjusted_start_time # 计算剩余时间 remaining_time = interval_seconds - (elapsed_time % interval_seconds) if remaining_time <= 0: remaining_time = interval_seconds remaining_seconds = math.ceil(remaining_time) if remaining_seconds <= 1: return "即将刷新..." else: return f"还有 {remaining_seconds} 秒" # 同时需要在自动刷新触发时重置开始时间 @app.callback( Output('refresh-start-time', 'data', allow_duplicate=True), [Input('auto-refresh-interval', 'n_intervals')], [State('auto-refresh-settings', 'data')], prevent_initial_call=True ) def reset_refresh_start_time(auto_refresh_intervals, settings): """重置刷新开始时间""" if auto_refresh_intervals > 0 and settings and settings.get('enabled'): from datetime import datetime return datetime.now().timestamp() return dash.no_update return app def run_app(debug=True, port=8050): """运行Web应用""" app = create_app() app.run(debug=debug, port=port, host='0.0.0.0')