From d99112c1601c211fba7a3fb0ba529becb648ca0c Mon Sep 17 00:00:00 2001 From: wshy Date: Tue, 14 Jul 2026 15:46:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=86=E6=97=B6=E5=9B=BE=E5=AE=9E=E6=97=B6?= =?UTF-8?q?=E5=88=B7=E6=96=B0:=20=E6=97=A5=E6=9C=9F=E4=BF=AE=E5=A4=8D=20+?= =?UTF-8?q?=20=E9=97=B4=E9=9A=94=E5=8F=AF=E9=85=8D=20+=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E6=A1=86=E5=AE=9E=E6=97=B6=20+=20=E8=87=AA=E9=80=89?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E5=9B=BE=E6=A0=87=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(kline): 分时图盘中显示昨天而非今天 get_minute_batch 的日期选择存在死循环: trade_date=date.today() 后立即 if trade_date==date.today() (恒真), 无条件用 latest_minute_date_global() 覆盖今天; 但批量实时补拉不落库, 该值盘中恒返回昨天 -> 永远回退到昨天 -> expected=240 判定昨日完整 -> 不触发今天补拉。 改为仅在真正非交易日回退 (周末 + 收盘后无今日日K 的节假日), 盘中保持 今天让 completeness 检查触发实时补拉。同时全量 date.today()/datetime.now() 改用北京时间 cn_today()/cn_now(), 与 quote_service/monitor 等一致。 影响: 自选列表分时列 + 单股详情分时图。 * feat(settings): 分时图刷新间隔可配置 (默认6s, 范围3-60s) 原写死 15s 轮询, 现可在 实时监控设置 -> 分时图刷新 卡片用滑块调节。 复用 minute_intraday_refresh 偏好链路, 新增 minute_intraday_refresh_interval 字段 (preferences + settings API + 前端 Preferences 接口)。 - preferences.py: getter 默认6s clamp[3,60] + set/get config 接入 - settings.py: RealtimeMonitorConfigIn 字段 + get_preferences 返回 - Monitoring.tsx: 滑块控件 (参考行情轮询滑块, 2s 防抖保存) - Watchlist/Screener: 15_000 -> interval*1000, fallback ?? 6 * feat(dialog): 个股对话框日K/分时实时刷新 日K走 SSE 精准刷新: 对话框打开时注册焦点股票, quotes_updated 推送时 invalidate ['kline', symbol], 后端 _maybe_inject_live_candle 只读内存 不调 TickFlow, 秒级零成本。关闭/切股自动注销焦点。 分时走轮询: 复用分时刷新开关 + 间隔偏好, 经 StockPanel.refetchIntervalMs 透传到 StockIntradayChart。分时端点数据不完整会调 TickFlow, 不接 SSE 避免打爆限流。 不影响回测弹窗 (不注册焦点、不传间隔)。 * feat(watchlist): 分时列表头加刷新图标 (与策略列表一致) 分时列可见 + 未自动刷新时显示手动刷新按钮 (点击 refetch, 加载旋转); 自动刷新中显示持续旋转小图标提示。逻辑/样式对齐 ScreenerTable。 * fix(badge): 真假涨停修正弹层被父级裁剪遮挡 弹出层原用 absolute 定位, 被看板卡片父级的 overflow-hidden + backdrop-blur containing block 裁剪/遮挡。改用 createPortal 渲染到 body, 基于徽章 getBoundingClientRect 计算弹层坐标, 脱离父级裁剪。 --------- Co-authored-by: shy3130 --- backend/app/api/kline.py | 64 ++++++-- backend/app/api/settings.py | 9 +- backend/app/services/preferences.py | 18 +++ frontend/src/components/SealedBadge.tsx | 152 ++++++++++-------- .../src/components/StockIntradayChart.tsx | 4 + frontend/src/components/StockPanel.tsx | 4 + .../src/components/StockPreviewDialog.tsx | 22 +++ frontend/src/lib/api.ts | 3 + frontend/src/lib/useQuoteStream.ts | 21 +++ frontend/src/pages/Screener.tsx | 5 +- frontend/src/pages/Watchlist.tsx | 23 ++- frontend/src/pages/settings/Monitoring.tsx | 49 +++++- 12 files changed, 286 insertions(+), 88 deletions(-) diff --git a/backend/app/api/kline.py b/backend/app/api/kline.py index ba9d96c..dbf58d3 100644 --- a/backend/app/api/kline.py +++ b/backend/app/api/kline.py @@ -8,6 +8,7 @@ from typing import Optional from fastapi import APIRouter, HTTPException, Query, Request from app.indicators.pipeline import compute_enriched, compute_enriched_single +from app.market_time import cn_now, cn_today from app.services import kline_sync logger = logging.getLogger(__name__) @@ -405,17 +406,30 @@ def get_minute_batch(request: Request, body: dict): if not capset.has(Cap.KLINE_MINUTE_BATCH): raise HTTPException(status_code=403, detail="需要 Pro+ 权限 (kline.minute.batch)") - trade_date = date.fromisoformat(trade_date_str) if trade_date_str else date.today() + trade_date = date.fromisoformat(trade_date_str) if trade_date_str else cn_today() - # 非交易日(周末/节假日)回退到最近有数据的交易日, 否则前端显示空白。 - # 优先用本地分钟K最近日期; 本地从未同步过分钟K时, 回退到日K最近交易日 - # (enriched 最新日一定有, 作为兜底), 确保 TickFlow 能拉到有效数据。 - if trade_date == date.today(): - recent_date = repo.latest_minute_date_global() - if recent_date is None: - recent_date = repo.latest_daily_date() - if recent_date is not None: - trade_date = recent_date + # 非交易日(周末/节假日)才回退到最近有数据的交易日; 否则盘中会显示昨天而非今天。 + # 注意: 不能用 latest_minute_date_global() 判断盘中是否为交易日 —— 批量实时补拉 + # 不落库 (见下方 sync_minute_batch 无 on_segment), 盘中它恒返回上次全量同步日, + # 用它做判据会导致 trade_date 永久回退到昨天, 再因 expected=240 判定昨日"完整" + # 而不再补拉今天, 形成永远显示昨日的死循环。 + # 判据改为: 周末必回退; 工作日收盘后(>=15:30)仍无今日日K → 节假日, 回退。 + if not trade_date_str: + today = cn_today() + need_fallback = today.weekday() >= 5 # 周六/周日必非交易日 + if not need_fallback: + now_cn = cn_now() + after_close = now_cn.hour > 15 or (now_cn.hour == 15 and now_cn.minute >= 30) + if after_close: + latest_daily = repo.latest_daily_date() + if latest_daily is None or latest_daily < today: + need_fallback = True + if need_fallback: + recent_date = repo.latest_minute_date_global() + if recent_date is None: + recent_date = repo.latest_daily_date() + if recent_date is not None: + trade_date = recent_date # Step 1: 本地优先 — 一次 scan 读全部 symbol 当日分钟K (股票 / ETF 分钟数据分开存储) etf_set = repo.get_etf_symbol_set() @@ -430,9 +444,9 @@ def get_minute_batch(request: Request, body: dict): df_local = pl.concat([df_local, df_etf], how="diagonal_relaxed") # 期望条数 (盘中按当前时刻估算, 盘后 240) - now = datetime.now() + now = cn_now() h, m = now.hour, now.minute - if trade_date != date.today(): + if trade_date != cn_today(): expected = 240 elif h < 9 or (h == 9 and m < 30): expected = 0 @@ -496,10 +510,27 @@ def get_minute( stock_name = stock_info.get("name") if trade_date is None: - trade_date = repo.latest_minute_date(symbol, asset_type=asset_type) + # 默认看今天, 而不是本地落盘的最近日 (盘中后者是昨天)。 + # 非交易日(周末/节假日)才回退到本地最近有数据的交易日。 + today = cn_today() + need_fallback = today.weekday() >= 5 # 周六/周日必非交易日 + if not need_fallback: + now_cn = cn_now() + after_close = now_cn.hour > 15 or (now_cn.hour == 15 and now_cn.minute >= 30) + if after_close: + latest_daily = repo.latest_daily_date() + if latest_daily is None or latest_daily < today: + need_fallback = True + if need_fallback: + recent = repo.latest_minute_date(symbol, asset_type=asset_type) + if recent is None: + recent = repo.latest_daily_date() + trade_date = recent if recent is not None else today + else: + trade_date = today if trade_date is None: # 本地无任何分钟K,尝试从 TickFlow 拉取当天 - trade_date = date.today() + trade_date = cn_today() df = kline_sync.fetch_minute_single(symbol, trade_date) return { "symbol": symbol, "name": stock_name, "stock_info": stock_info, @@ -510,10 +541,9 @@ def get_minute( # 完整交易日应有 240 条分钟K;如果是今天(盘中),期望条数按已交易分钟估算 expected = 240 - today = date.today() + today = cn_today() if trade_date == today: - from datetime import datetime as _dt - now = _dt.now() + now = cn_now() h, m = now.hour, now.minute if h < 9 or (h == 9 and m < 30): expected = 0 # 还没开盘 diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index f934520..51d83fe 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -429,6 +429,7 @@ def get_preferences() -> dict: "webhook_default_channels": preferences.get_webhook_default_channels(), "sidebar_index_symbols": preferences.get_sidebar_index_symbols(), "minute_intraday_refresh": preferences.get_minute_intraday_refresh(), + "minute_intraday_refresh_interval": preferences.get_minute_intraday_refresh_interval(), "nav_order": preferences.get_nav_order(), "nav_hidden": preferences.get_nav_hidden(), "screener_auto_run": preferences.get_screener_auto_run(), @@ -754,6 +755,7 @@ class RealtimeMonitorConfigIn(BaseModel): sidebar_index_symbols: list[str] | None = None screener_auto_run: bool | None = None minute_intraday_refresh: bool | None = None + minute_intraday_refresh_interval: int | None = None @router.put("/preferences/realtime-monitor") @@ -1311,7 +1313,12 @@ def run_limit_ladder_fix(request: Request) -> dict: depth_svc = getattr(request.app.state, "depth_service", None) if not depth_svc: raise HTTPException(status_code=503, detail="depth 服务未初始化") - return depth_svc.run_once() + result = depth_svc.run_once() + # sealed 数据变了, 清看板总览缓存, 否则看板在 TTL 窗口内仍返回旧的 limit_up/fake 等 + if result.get("ok"): + from app.api.overview import invalidate_overview_cache + invalidate_overview_cache() + return result class DepthPollingIntervalIn(BaseModel): diff --git a/backend/app/services/preferences.py b/backend/app/services/preferences.py index 50e7f1b..bdfa222 100644 --- a/backend/app/services/preferences.py +++ b/backend/app/services/preferences.py @@ -107,6 +107,18 @@ def get_minute_intraday_refresh() -> bool: return False +# 分时图实时刷新间隔允许范围 (秒)。下限 3s, 上限 60s。 +_INTRADAY_REFRESH_INTERVAL_MIN = 3 +_INTRADAY_REFRESH_INTERVAL_MAX = 60 + + +def get_minute_intraday_refresh_interval() -> int: + """分时图实时刷新轮询间隔 (秒)。默认 6s, 范围 [3, 60]。""" + return max(_INTRADAY_REFRESH_INTERVAL_MIN, + min(_INTRADAY_REFRESH_INTERVAL_MAX, + int(load().get("minute_intraday_refresh_interval", 6)))) + + def get_minute_sync_days() -> int: return max(1, min(30, load().get("minute_sync_days", 5))) @@ -628,6 +640,11 @@ def set_realtime_monitor_config(cfg: dict) -> dict: updates["screener_auto_run"] = bool(cfg["screener_auto_run"]) if "minute_intraday_refresh" in cfg: updates["minute_intraday_refresh"] = bool(cfg["minute_intraday_refresh"]) + if "minute_intraday_refresh_interval" in cfg: + # clamp 到 [5, 60], 与 getter 一致, 防前端传越界值 + updates["minute_intraday_refresh_interval"] = max( + _INTRADAY_REFRESH_INTERVAL_MIN, + min(_INTRADAY_REFRESH_INTERVAL_MAX, int(cfg["minute_intraday_refresh_interval"]))) if updates: save(updates) return get_realtime_monitor_config() @@ -642,6 +659,7 @@ def get_realtime_monitor_config() -> dict: "sidebar_index_symbols": get_sidebar_index_symbols(), "screener_auto_run": get_screener_auto_run(), "minute_intraday_refresh": get_minute_intraday_refresh(), + "minute_intraday_refresh_interval": get_minute_intraday_refresh_interval(), } diff --git a/frontend/src/components/SealedBadge.tsx b/frontend/src/components/SealedBadge.tsx index 191ba6d..1e76a2c 100644 --- a/frontend/src/components/SealedBadge.tsx +++ b/frontend/src/components/SealedBadge.tsx @@ -1,4 +1,5 @@ -import { useState } from 'react' +import { useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { useNavigate } from 'react-router-dom' import { useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' @@ -54,6 +55,8 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea invalidateKeys?: string[] }) { const [showHint, setShowHint] = useState(false) + const [hintPos, setHintPos] = useState<{ left: number; top: number } | null>(null) + const btnRef = useRef(null) const navigate = useNavigate() const qc = useQueryClient() const runFix = useMutation({ @@ -74,70 +77,91 @@ export function SealedBadge({ degraded, hasDepth, isHistorical, sealedReady, sea const label = degraded ? '降级' : '修正' return ( -
- - - {showHint && ( - <> -
setShowHint(false)} /> - e.stopPropagation()} - > - {degraded ? ( - <> -
真假涨停判定降级
- {reasons.map((r, i) => ( -
- · - {r} + <> +
+ +
+ {createPortal( + + {showHint && hintPos && ( + <> +
setShowHint(false)} /> + e.stopPropagation()} + > + {degraded ? ( + <> +
真假涨停判定降级
+ {reasons.map((r, i) => ( +
+ · + {r} +
+ ))} +
+ 真假板判定依赖五档盘口实时快照(卖一/买一量)。Pro+ 套餐的当天数据在收盘后自动恢复。
- ))} -
- 真假板判定依赖五档盘口实时快照(卖一/买一量)。Pro+ 套餐的当天数据在收盘后自动恢复。 -
- - ) : ( - <> -
五档盘口修正结果
- - -
- 真封板显示封单量,假涨停/假跌停已归入炸板/翘板视图。{sealedReady && '数据为盘中快照,收盘后自动定版。'} -
- - )} -
- {hasDepth && !isHistorical && ( - + + ) : ( + <> +
五档盘口修正结果
+ + +
+ 真封板显示封单量,假涨停/假跌停已归入炸板/翘板视图。{sealedReady && '数据为盘中快照,收盘后自动定版。'} +
+ )} - -
-
- - )} - -
+
+ {hasDepth && !isHistorical && ( + + )} + +
+ + + )} +
, + document.body + )} + ) } diff --git a/frontend/src/components/StockIntradayChart.tsx b/frontend/src/components/StockIntradayChart.tsx index 411870f..7055714 100644 --- a/frontend/src/components/StockIntradayChart.tsx +++ b/frontend/src/components/StockIntradayChart.tsx @@ -12,6 +12,8 @@ interface Props { prevClose?: number className?: string onPriceHover?: (price: number | null) => void + /** 自动刷新间隔(ms)。undefined/0 = 不轮询(默认)。个股对话框盘中实时刷新时传入。 */ + refetchIntervalMs?: number } export function StockIntradayChart({ @@ -21,6 +23,7 @@ export function StockIntradayChart({ prevClose, className, onPriceHover, + refetchIntervalMs, }: Props) { const qc = useQueryClient() const [minuteDismissed, setMinuteDismissed] = useState(false) @@ -29,6 +32,7 @@ export function StockIntradayChart({ queryKey: QK.klineMinute(symbol, date ?? ''), queryFn: () => api.klineMinute(symbol, date ?? undefined), enabled: !!symbol && !!date, + refetchInterval: refetchIntervalMs, }) const fetchMinute = useMutation({ diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 7385c0d..e6e496c 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -32,6 +32,8 @@ interface Props { /** 加自选 (传入后信息条显示 Star 图标) */ inWatchlist?: boolean onToggleWatchlist?: () => void + /** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */ + refetchIntervalMs?: number } export { getDefaultRange } @@ -51,6 +53,7 @@ export function StockPanel({ onMonitor, inWatchlist, onToggleWatchlist, + refetchIntervalMs, }: Props) { const [linkedPrice, setLinkedPrice] = useState(null) const [selectedDate, setSelectedDate] = useState(null) @@ -158,6 +161,7 @@ export function StockPanel({ prevClose={prevClose} onPriceHover={setLinkedPrice} className="flex-1 min-w-0 border-l border-border pl-3" + refetchIntervalMs={refetchIntervalMs} /> )}
diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 4c49108..b2c25cd 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -8,6 +8,8 @@ import { cnSignal } from '@/lib/signals' import { StockPanel, getDefaultRange } from '@/components/StockPanel' import { DatePicker } from '@/components/DatePicker' import { RuleEditor } from '@/components/monitor/RuleEditor' +import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' +import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream' interface Props { symbol: string | null @@ -69,6 +71,25 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props return () => document.removeEventListener('keydown', handler) }, [symbol, onClose]) + // 焦点股票注册: SSE quotes_updated 推送时精准 invalidate 当前股票日K, + // 让对话框日K最后一根蜡烛随实时价变化 (后端只读内存, 不调 TickFlow)。 + // 关闭/切股时清除, 避免无谓刷新。 + useEffect(() => { + if (!symbol) return + setFocusSymbol(symbol) + return () => clearFocusSymbol() + }, [symbol]) + + // 分时图实时轮询: 复用自选列表的「分时刷新开关 + 间隔」偏好。 + // 仅实时行情运行 且 用户开启分时刷新时才轮询; 否则 undefined (定格)。 + const { data: prefs } = usePreferences() + const { data: quoteStatus } = useQuoteStatus() + const realtimeRunning = quoteStatus?.running ?? false + const intradayRefreshOn = prefs?.minute_intraday_refresh ?? false + const intradayRefetchMs = (intradayRefreshOn && realtimeRunning) + ? (prefs?.minute_intraday_refresh_interval ?? 6) * 1000 + : undefined + const handleRefresh = () => { if (!symbol) return qc.invalidateQueries({ queryKey: ['kline', symbol!] }) @@ -241,6 +262,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props onMonitor={() => setShowMonitorEditor(true)} inWatchlist={inWatchlist} onToggleWatchlist={() => toggleWatchlist.mutate()} + refetchIntervalMs={intradayRefetchMs} />
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index e266f94..7bfa885 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -846,6 +846,7 @@ export interface Preferences { nav_hidden: string[] screener_auto_run: boolean minute_intraday_refresh: boolean + minute_intraday_refresh_interval: number } export interface StrategyAlertEvent { source: 'strategy' | 'depth' @@ -1024,6 +1025,7 @@ export const api = { sidebar_index_symbols?: string[] screener_auto_run?: boolean minute_intraday_refresh?: boolean + minute_intraday_refresh_interval?: number }) => request<{ sse_refresh_pages: Record @@ -1032,6 +1034,7 @@ export const api = { sidebar_index_symbols: string[] screener_auto_run: boolean minute_intraday_refresh: boolean + minute_intraday_refresh_interval: number }>('/api/settings/preferences/realtime-monitor', { method: 'PUT', body: JSON.stringify(cfg), diff --git a/frontend/src/lib/useQuoteStream.ts b/frontend/src/lib/useQuoteStream.ts index 834d20f..867c46f 100644 --- a/frontend/src/lib/useQuoteStream.ts +++ b/frontend/src/lib/useQuoteStream.ts @@ -46,6 +46,22 @@ export function useQuoteStreamStatus(): QuoteStreamStatus { return useSyncExternalStore(_subscribeStatus, _getStatus, () => 'disconnected' as const) } +// ===== 焦点股票注册表 (个股对话框用) ===== +// 个股对话框打开时注册当前 symbol, SSE quotes_updated 推送时精准 invalidate +// 该 symbol 的日K查询 (['kline', symbol]), 让日K最后一根蜡烛随实时价变化。 +// 不加进 SSE_INVALIDATE_PREFIXES 全局列表 —— 避免回测弹窗等也每秒重拉。 +let _focusSymbol: string | null = null + +/** 注册当前焦点股票 (个股对话框打开时调用)。 */ +export function setFocusSymbol(symbol: string): void { + _focusSymbol = symbol +} + +/** 清除焦点股票 (个股对话框关闭时调用)。 */ +export function clearFocusSymbol(): void { + _focusSymbol = null +} + /** * 全局 SSE hook: 监听后端行情更新推送 + 策略监控通知。 * @@ -138,6 +154,11 @@ export function useQuoteStream( ), }) } + // 焦点股票日K精准刷新: 个股对话框打开时, 日K最后一根蜡烛随实时价变化。 + // 后端 _maybe_inject_live_candle 只读内存缓存, 不调 TickFlow, 秒级重拉零额外成本。 + if (_focusSymbol) { + qc.invalidateQueries({ queryKey: ['kline', _focusSymbol] }) + } }) es.addEventListener('strategy_results_updated', () => { diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index 2a43ba3..39e3d7f 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -364,12 +364,13 @@ export function Screener() { // 分时数据加载策略 (与自选页一致, 简洁优先): // - 全量加载当前列表 symbol, 但按套餐 batch 上限截断 (Pro=100 / Expert=200), // 超出时只取前 batch 只并提示用户, 避免一次性发太多请求打爆 rpm 配额 - // - 刷新: minute_intraday_refresh 偏好开启时盘中 15s 轮询; 否则仅首次加载, + // - 刷新: minute_intraday_refresh 偏好开启时按用户设定间隔轮询; 否则仅首次加载, // 用户可点表头刷新按钮手动更新 const minuteBatchCap = caps.data?.capabilities?.['kline.minute.batch']?.batch ?? 100 const quoteStatus = useQuoteStatus() const realtimeRunning = quoteStatus.data?.running ?? false const intradayRefreshEnabled = prefs?.minute_intraday_refresh ?? false + const intradayRefreshInterval = prefs?.minute_intraday_refresh_interval ?? 6 const allIntradaySymbols = useMemo( () => displayRows.map((r: any) => r.symbol), @@ -391,7 +392,7 @@ export function Screener() { enabled: intradayVisible && intradaySymbols.length > 0, staleTime: 10_000, // 仅当开启分时刷新偏好 且 盘中实时行情运行时 才轮询 (省 rpm) - refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? 15_000 : false, + refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false, }) const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {} diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index 357162b..0586a22 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -626,15 +626,16 @@ export function Watchlist() { // 批量分时数据 (Pro+ 用户, 列可见时才拉) // 刷新策略: 仅当实时行情运行 且 用户在实时监控设置里开启 minute_intraday_refresh 时 - // 按 15s 轮询 (不接 SSE 高频, 避免每秒拉 TickFlow 触限流); 与 Screener / 设置卡片描述一致。 + // 按用户设定的间隔轮询 (不接 SSE 高频, 避免每秒拉 TickFlow 触限流); 与 Screener / 设置卡片描述一致。 const { data: prefsData } = usePreferences() const intradayRefreshEnabled = prefsData?.minute_intraday_refresh ?? false + const intradayRefreshInterval = prefsData?.minute_intraday_refresh_interval ?? 6 const minuteBatch = useQuery({ queryKey: QK.minuteBatch(symbolsKey), queryFn: () => api.klineMinuteBatch(symbols), enabled: intradayVisible && symbols.length > 0, staleTime: 10_000, - refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? 15_000 : false, + refetchInterval: (intradayRefreshEnabled && realtimeRunning) ? intradayRefreshInterval * 1000 : false, }) const minuteData = intradayVisible ? (minuteBatch.data?.data ?? {}) : {} @@ -1063,6 +1064,7 @@ export function Watchlist() { ) } if (col.source.type === 'builtin' && col.source.key === 'intraday') { + const intradayAutoRefresh = intradayRefreshEnabled && realtimeRunning return ( {col.label} @@ -1079,6 +1081,23 @@ export function Watchlist() { > {intradayChartVisible ? : } + {/* 分时图显示 且 未开自动轮询时, 提供手动刷新按钮 */} + {intradayChartVisible && !intradayAutoRefresh && ( + + )} + {/* 自动轮询中: 显示旋转图标提示正在实时刷新 */} + {intradayChartVisible && intradayAutoRefresh && ( + + )} ) } diff --git a/frontend/src/pages/settings/Monitoring.tsx b/frontend/src/pages/settings/Monitoring.tsx index 504843b..6fc59ba 100644 --- a/frontend/src/pages/settings/Monitoring.tsx +++ b/frontend/src/pages/settings/Monitoring.tsx @@ -51,6 +51,10 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = const isNoneTier = tier < 0 const isFreeTier = tier === 0 const realtimeEnabled = prefs?.realtime_quotes_enabled ?? false + // 分时图实时刷新间隔 (秒), 与后端 [3,60] clamp 对齐; 默认 6 + const intradayInterval = prefs?.minute_intraday_refresh_interval ?? 6 + // 滑块本地草稿: 拖动时即时反馈, 停顿 2s 后落库 (与行情轮询滑块一致) + const [intradayIntervalDraft, setIntradayIntervalDraft] = useState(intradayInterval) const refreshPages = prefs?.sse_refresh_pages ?? {} const limitLadderMonitor = prefs?.limit_ladder_monitor_enabled ?? false const hasDepth = !!caps?.capabilities?.['depth5.batch'] @@ -245,6 +249,20 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = return () => window.clearTimeout(t) }, [intervalDraft, interval, updateInterval]) + // 分时刷新间隔: 服务端值变化时同步本地草稿 + useEffect(() => { + setIntradayIntervalDraft(intradayInterval) + }, [intradayInterval]) + + // 分时刷新间隔: 草稿与已保存值不同时, 2s 防抖落库 + useEffect(() => { + if (intradayIntervalDraft === intradayInterval) return + const t = window.setTimeout(() => { + save({ minute_intraday_refresh_interval: intradayIntervalDraft }) + }, 2000) + return () => window.clearTimeout(t) + }, [intradayIntervalDraft, intradayInterval, save]) + // highlight=depth-fix 时闪烁高亮连板梯队修正卡片 const [flash, setFlash] = useState(false) const flashedRef = useRef(false) @@ -387,14 +405,41 @@ export function SettingsMonitoringPanel({ highlight }: { highlight?: string } = )} - {/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中 15s 轮询刷新分时数据) */} + {/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中按设定间隔轮询刷新分时数据) */} save({ minute_intraday_refresh: v })} /> +
+
+
+
刷新间隔
+
+ 间隔越短更新越及时, 但越耗数据源配额 (rpm) +
+
+ + {intradayIntervalDraft}s + +
+
+ setIntradayIntervalDraft(parseInt(e.target.value, 10))} + className="flex-1 h-1 accent-accent cursor-pointer" + /> + + {intradayIntervalDraft !== intradayInterval ? '2秒后保存' : '3s — 60s'} + +
+
{!isFreeTier && (