mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 23:44:16 +08:00
* 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>
171 lines
6.1 KiB
TypeScript
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>
|
|
)
|
|
}
|