Files
tick-stock-panel/frontend/src/components/StockPanel.tsx
T
wshyandshy3130 d99112c160 分时图实时刷新: 日期修复 + 间隔可配 + 对话框实时 + 自选刷新图标 (#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>
2026-07-14 15:46:18 +08:00

171 lines
6.1 KiB
TypeScript

import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
import { StockInfoBar } from '@/components/StockInfoBar'
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
import { StockIntradayChart } from '@/components/StockIntradayChart'
import { useFinancialMetrics } from '@/lib/useFinancials'
import { useCapabilities } from '@/lib/useSharedQueries'
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import {
loadInfoFields,
saveInfoFields,
buildInfoExtColumnsParam,
type ColumnConfig,
} from '@/lib/stock-info-fields'
interface Props {
symbol: string
height?: number
showIntraday?: boolean
className?: string
/** 当用户点击蜡烛选中日期时回调(用于外部自动开启分时图)。 */
onSelectDate?: (date: string) => void
/** 外部传入的日期范围 */
dateRange?: { start: string; end: string }
markers?: ChartMarker[]
ranges?: ChartRange[]
priceLines?: ChartPriceLine[]
showLimitMarkers?: boolean
showMarkerToggle?: boolean
/** 加监控回调 (传入后信息条显示 RadioTower 图标) */
onMonitor?: () => void
/** 加自选 (传入后信息条显示 Star 图标) */
inWatchlist?: boolean
onToggleWatchlist?: () => void
/** 分时图自动刷新间隔(ms)。undefined = 不轮询。个股对话框盘中实时刷新时传入。 */
refetchIntervalMs?: number
}
export { getDefaultRange }
export function StockPanel({
symbol,
height = 520,
showIntraday = true,
className,
onSelectDate,
dateRange: externalDateRange,
markers,
ranges,
priceLines,
showLimitMarkers = true,
showMarkerToggle = true,
onMonitor,
inWatchlist,
onToggleWatchlist,
refetchIntervalMs,
}: Props) {
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields])
const handleFieldsChange = useCallback((next: ColumnConfig[]) => {
setFields(next)
saveInfoFields(next)
}, [])
// 财务指标:仅当信息条配置含可见的财务字段且用户具备 FINANCIAL 能力 (Expert) 时才请求
// 无能力时跳过请求, 避免后端抛 CapabilityDenied (403) 导致 free/starter 档弹错误提示
const { data: caps } = useCapabilities()
const hasFinancialCap = !!caps?.capabilities?.['financial']
const hasFinanceField = useMemo(
() => fields.some(f => f.visible && f.source.type === 'builtin'
&& ['eps', 'bps', 'roe', 'pe_ttm', 'pb', 'gross_margin', 'net_margin', 'debt_ratio', 'revenue_yoy', 'net_income_yoy'].includes(f.source.key)),
[fields],
)
const financials = useFinancialMetrics(hasFinanceField && hasFinancialCap ? symbol : undefined)
const dateRange = externalDateRange ?? getDefaultRange()
const handleDateClick = useCallback((date: string) => {
setSelectedDate(date)
onSelectDate?.(date)
}, [onSelectDate])
const rows = dailyResult?.rows ?? []
const stockInfo = dailyResult?.stockInfo
const rawRows: KlineRow[] = dailyResult?.rawRows ?? []
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
// 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存,
// 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据,
// 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。
const prevSymbol = useRef<string | null>(symbol)
useEffect(() => {
if (prevSymbol.current === symbol) return
prevSymbol.current = symbol
setSelectedDate(null)
setLinkedPrice(null)
setDailyResult(null)
}, [symbol])
// 当分时开启、无选中日期时,自动选中最新日期
useEffect(() => {
if (showIntraday && !selectedDate && rows.length > 0) {
setSelectedDate(rows[rows.length - 1].date)
}
}, [showIntraday, selectedDate, rows])
const selectedIdx = selectedDate ? rows.findIndex(r => r.date === selectedDate) : -1
const prevClose = selectedIdx > 0
? rows[selectedIdx - 1].close
: rows.length >= 2
? rows[rows.length - 2].close
: undefined
if (!symbol) return null
// 财务指标最新一期(metrics 按 period_end 排序,取首项)
const financialMetrics: FinancialMetricRecord | undefined = financials.data?.data?.[0]
return (
<div className={className}>
<StockInfoBar
symbol={symbol}
name={dailyResult?.name}
stockInfo={stockInfo}
rows={rawRows}
fields={fields}
onFieldsChange={handleFieldsChange}
financialMetrics={financialMetrics}
onMonitor={onMonitor}
inWatchlist={inWatchlist}
onToggleWatchlist={onToggleWatchlist}
/>
<div className="flex gap-3 items-start">
<StockDailyKChart
symbol={symbol}
height={height}
className="flex-1 min-w-0"
dateRange={dateRange}
markers={markers}
ranges={ranges}
priceLines={priceLines}
showLimitMarkers={showLimitMarkers}
showMarkerToggle={showMarkerToggle}
linkedPrice={linkedPrice}
onDateClick={handleDateClick}
onDataChange={setDailyResult}
visibleBars={showIntraday ? 40 : 60}
extColumns={extColumns}
/>
{showIntraday && selectedDate && (
<StockIntradayChart
symbol={symbol}
date={selectedDate}
height={height}
prevClose={prevClose}
onPriceHover={setLinkedPrice}
className="flex-1 min-w-0 border-l border-border pl-3"
refetchIntervalMs={refetchIntervalMs}
/>
)}
</div>
</div>
)
}