分时图实时刷新: 日期修复 + 间隔可配 + 对话框实时 + 自选刷新图标 (#114)

* 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 <shy3130@users.noreply.github.com>
This commit is contained in:
wshy
2026-07-14 15:46:18 +08:00
committed by GitHub
co-authored by shy3130
parent 77c0c643b2
commit d99112c160
12 changed files with 286 additions and 88 deletions
+47 -17
View File
@@ -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 # 还没开盘
+8 -1
View File
@@ -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):
+18
View File
@@ -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(),
}
+88 -64
View File
@@ -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<HTMLButtonElement>(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 (
<div className="relative inline-flex items-center">
<button
onClick={() => setShowHint(v => !v)}
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
>
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
</button>
<AnimatePresence>
{showHint && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowHint(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
className="absolute top-full left-0 mt-1 z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
onClick={e => e.stopPropagation()}
>
{degraded ? (
<>
<div className="font-medium text-foreground mb-1.5"></div>
{reasons.map((r, i) => (
<div key={i} className="flex gap-1 mb-1">
<span className="text-yellow-500 shrink-0">·</span>
<span>{r}</span>
<>
<div className="relative inline-flex items-center">
<button
ref={btnRef}
onClick={() => {
// 基于徽章位置算弹层坐标, 通过 portal 渲染到 body
// 脱离父级 overflow-hidden 裁剪 + backdrop-blur 的 containing block
const rect = btnRef.current?.getBoundingClientRect()
if (rect) {
const W = 256 // w-64 = 16rem = 256px
const H = 240 // 预估高度 (降级原因/修正明细 + 按钮区)
const left = Math.max(8, Math.min(rect.left, window.innerWidth - W - 8))
const top = rect.bottom + H > window.innerHeight
? Math.max(8, rect.top - H - 4)
: rect.bottom + 4
setHintPos({ left, top })
}
setShowHint(v => !v)
}}
className="group inline-flex items-center gap-1 h-5 px-2 rounded-full bg-yellow-500/10 border border-yellow-500/30 cursor-help transition-all hover:bg-yellow-500/20 hover:border-yellow-500/50"
>
<span className="h-1.5 w-1.5 rounded-full bg-yellow-500" />
<span className="text-[10px] font-medium text-yellow-600 dark:text-yellow-500 leading-none">{label}</span>
<HelpCircle className="h-3 w-3 text-yellow-500/70 group-hover:text-yellow-500 transition-colors" />
</button>
</div>
{createPortal(
<AnimatePresence>
{showHint && hintPos && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowHint(false)} />
<motion.div
initial={{ opacity: 0, y: -4, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -4, scale: 0.95 }}
style={{ left: hintPos.left, top: hintPos.top }}
className="fixed z-50 w-64 bg-surface border border-border rounded-md shadow-xl p-3 text-[11px] text-secondary leading-relaxed"
onClick={e => e.stopPropagation()}
>
{degraded ? (
<>
<div className="font-medium text-foreground mb-1.5"></div>
{reasons.map((r, i) => (
<div key={i} className="flex gap-1 mb-1">
<span className="text-yellow-500 shrink-0">·</span>
<span>{r}</span>
</div>
))}
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
(/)Pro+
</div>
))}
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
(/)Pro+
</div>
</>
) : (
<>
<div className="font-medium text-foreground mb-1.5"></div>
<SealedDirBlock title="涨停" color="bull" counts={sealedCountsUp} rawTotal={rawUp} />
<SealedDirBlock title="跌停" color="bear" counts={sealedCountsDown} rawTotal={rawDown} />
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
,//{sealedReady && '数据为盘中快照,收盘后自动定版。'}
</div>
</>
)}
<div className="mt-2 flex gap-1.5">
{hasDepth && !isHistorical && (
<button
onClick={() => { runFix.mutate(); setShowHint(false) }}
disabled={runFix.isPending}
className="flex-1 px-2 py-1.5 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors text-center disabled:opacity-50"
>
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
</>
) : (
<>
<div className="font-medium text-foreground mb-1.5"></div>
<SealedDirBlock title="涨停" color="bull" counts={sealedCountsUp} rawTotal={rawUp} />
<SealedDirBlock title="跌停" color="bear" counts={sealedCountsDown} rawTotal={rawDown} />
<div className="mt-1.5 pt-1.5 border-t border-border text-muted">
,//{sealedReady && '数据为盘中快照,收盘后自动定版。'}
</div>
</>
)}
<button
onClick={() => { setShowHint(false); navigate('/settings?tab=monitoring&highlight=depth-fix') }}
className={`${hasDepth && !isHistorical ? '' : 'w-full'} px-2 py-1.5 rounded text-[11px] bg-elevated text-secondary hover:text-foreground transition-colors text-center`}
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
<div className="mt-2 flex gap-1.5">
{hasDepth && !isHistorical && (
<button
onClick={() => { runFix.mutate(); setShowHint(false) }}
disabled={runFix.isPending}
className="flex-1 px-2 py-1.5 rounded text-[11px] bg-accent/15 text-accent hover:bg-accent/25 transition-colors text-center disabled:opacity-50"
>
{runFix.isPending ? '修正中…' : '立即修正'}
</button>
)}
<button
onClick={() => { setShowHint(false); navigate('/settings?tab=monitoring&highlight=depth-fix') }}
className={`${hasDepth && !isHistorical ? '' : 'w-full'} px-2 py-1.5 rounded text-[11px] bg-elevated text-secondary hover:text-foreground transition-colors text-center`}
>
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>,
document.body
)}
</>
)
}
@@ -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({
+4
View File
@@ -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<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(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}
/>
)}
</div>
@@ -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}
/>
</div>
+3
View File
@@ -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<string, boolean>
@@ -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),
+21
View File
@@ -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', () => {
+3 -2
View File
@@ -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 ?? {}) : {}
+21 -2
View File
@@ -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 (
<span className="inline-flex items-center justify-center gap-1.5">
<span>{col.label}</span>
@@ -1079,6 +1081,23 @@ export function Watchlist() {
>
{intradayChartVisible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
{/* 分时图显示 且 未开自动轮询时, 提供手动刷新按钮 */}
{intradayChartVisible && !intradayAutoRefresh && (
<button
type="button"
onClick={(event) => { event.stopPropagation(); minuteBatch.refetch() }}
disabled={minuteBatch.isFetching}
className="inline-flex items-center justify-center w-5 h-5 rounded text-muted hover:text-accent hover:bg-accent/10 transition-colors disabled:opacity-40"
title="刷新分时数据"
aria-label="刷新分时数据"
>
<RefreshCw className={`h-3.5 w-3.5 ${minuteBatch.isFetching ? 'animate-spin' : ''}`} />
</button>
)}
{/* 自动轮询中: 显示旋转图标提示正在实时刷新 */}
{intradayChartVisible && intradayAutoRefresh && (
<RefreshCw className="h-3 w-3 text-accent/60 animate-spin" aria-label="实时刷新中" />
)}
</span>
)
}
+47 -2
View File
@@ -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 } =
</Card>
)}
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中 15s 轮询刷新分时数据) */}
{/* 自选列表分时图实时刷新 (默认关闭, 开启后盘中按设定间隔轮询刷新分时数据) */}
<Card icon={Activity} title="分时图刷新">
<ToggleRow
label="自选/策略分时图实时刷新"
desc="开启后自选与策略列表的分时图盘中每 15 秒自动刷新(需 Pro+ 权限 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。"
desc={`开启后自选与策略列表的分时图盘中每 ${intradayInterval} 秒自动刷新(需 Pro+ 权限 + 实时行情运行)。关闭时仅打开页面时拉取一次, 可点表头刷新按钮手动更新。`}
checked={prefs?.minute_intraday_refresh ?? false}
onChange={(v) => save({ minute_intraday_refresh: v })}
/>
<div className="mt-3 pt-3 border-t border-border">
<div className="flex items-center justify-between gap-4 py-1">
<div className="min-w-0">
<div className="text-sm text-foreground"></div>
<div className="text-[11px] text-muted">
, (rpm)
</div>
</div>
<span className="text-[11px] font-mono text-foreground shrink-0 tabular-nums">
{intradayIntervalDraft}s
</span>
</div>
<div className="flex items-center gap-3 mt-2">
<input
type="range"
min={3}
max={60}
step={1}
value={intradayIntervalDraft}
onChange={(e) => setIntradayIntervalDraft(parseInt(e.target.value, 10))}
className="flex-1 h-1 accent-accent cursor-pointer"
/>
<span className="text-[10px] text-muted shrink-0">
{intradayIntervalDraft !== intradayInterval ? '2秒后保存' : '3s — 60s'}
</span>
</div>
</div>
</Card>
{!isFreeTier && (