mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 16:44:15 +08:00
feat(kline): prefetch neighbor stocks and stabilize dialog height
切股瞬间免 loading + 弹窗高度不抖动。 - 新建 lib/kline.ts: klineDailyQueryOptions 作为日K查询唯一权威配置, StockPanel(信息条)/StockDailyKChart(图表)/邻近预取三处共享同一 cache key - StockPanel: 日K查询上提并新增 prefetchSymbols prop, 预取左右邻股日K+财务, 日K到货后级联预取其默认选中日分时(klineMinute) —— 日K视图并排展示的分时图也免 loading - StockInfoBar: 无数据时保持挂载(加载态占位), 按字段配置预留行高, 高度不塌陷 - useFinancials: 抽出 financialMetricsQueryOptions 供 prefetch 复用 - StockDailyKChart: 查询改走共享 options, 移除 onDataChange 上报链路 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api, type KlineRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { type KlineRow } from '@/lib/api'
|
||||
import { klineDailyQueryOptions } from '@/lib/kline'
|
||||
import { storage } from '@/lib/storage'
|
||||
import {
|
||||
EChartsCandlestick,
|
||||
@@ -11,13 +11,11 @@ import {
|
||||
type ChartPriceLine,
|
||||
type ChartRange,
|
||||
type OHLC,
|
||||
type StockInfo,
|
||||
type VolumeCompareConfig,
|
||||
} from '@/components/EChartsCandlestick'
|
||||
|
||||
const SUB_INFO_H = 16
|
||||
const SUB_GAP = 4
|
||||
const MAX_DAYS = 2000
|
||||
const DEFAULT_VOLUME_COMPARE: VolumeCompareConfig = { enabled: true, days: 1 }
|
||||
|
||||
function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfig {
|
||||
@@ -27,13 +25,6 @@ function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfi
|
||||
}
|
||||
}
|
||||
|
||||
export interface StockDailyKChartResult {
|
||||
rows: OHLC[]
|
||||
rawRows: KlineRow[]
|
||||
stockInfo?: StockInfo
|
||||
name?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
height?: number
|
||||
@@ -51,7 +42,6 @@ interface Props {
|
||||
linkedPrice?: number | null
|
||||
onDateClick?: (date: string) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
onDataChange?: (result: StockDailyKChartResult) => void
|
||||
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
|
||||
extColumns?: string
|
||||
/** 日K自动刷新间隔(ms)。undefined = 不轮询(默认)。个股对话框实时刷新时传入, 盘中今日蜡烛随之更新 */
|
||||
@@ -113,12 +103,6 @@ export function getDefaultRange(): { start: string; end: string } {
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
function rangeDays(range: { start: string; end: string }): number {
|
||||
const start = new Date(range.start)
|
||||
const end = new Date(range.end)
|
||||
return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS)
|
||||
}
|
||||
|
||||
export function StockDailyKChart({
|
||||
symbol,
|
||||
height = 520,
|
||||
@@ -136,7 +120,6 @@ export function StockDailyKChart({
|
||||
linkedPrice,
|
||||
onDateClick,
|
||||
onPriceDoubleClick,
|
||||
onDataChange,
|
||||
extColumns,
|
||||
refetchIntervalMs,
|
||||
}: Props) {
|
||||
@@ -146,16 +129,10 @@ export function StockDailyKChart({
|
||||
normalizeVolumeCompare(storage.stockVolumeCompare.get(DEFAULT_VOLUME_COMPARE)),
|
||||
)
|
||||
const dateRange = externalDateRange ?? getDefaultRange()
|
||||
const days = useMemo(() => rangeDays(dateRange), [dateRange])
|
||||
|
||||
// extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数)
|
||||
const kline = useQuery({
|
||||
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
|
||||
queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns),
|
||||
enabled: !!symbol,
|
||||
refetchInterval: refetchIntervalMs,
|
||||
placeholderData: (prev) => prev,
|
||||
})
|
||||
// 查询配置统一来自 klineDailyQueryOptions, 与 StockPanel 信息条/邻近预取共享同一 cache key (只发一次请求)
|
||||
// 上游新增的 refetchIntervalMs(个股对话框盘中实时刷新今日蜡烛) 补挂在工厂配置之上
|
||||
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol, refetchInterval: refetchIntervalMs })
|
||||
|
||||
const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
|
||||
const stockInfo = kline.data?.stock_info
|
||||
@@ -185,10 +162,6 @@ export function StockDailyKChart({
|
||||
if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14
|
||||
const chartHeight = height + subExtraH
|
||||
|
||||
useEffect(() => {
|
||||
onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name })
|
||||
}, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo])
|
||||
|
||||
if (!symbol) return null
|
||||
|
||||
return (
|
||||
|
||||
@@ -122,7 +122,32 @@ export function StockInfoBar({
|
||||
})
|
||||
}
|
||||
|
||||
if (rows.length === 0) return null
|
||||
// 字段分组: 加载态预留高度与完整态渲染共用同一规则
|
||||
const visibleFields = fields.filter(f => f.visible)
|
||||
const inlineFields = visibleFields.filter(f => !f.standalone)
|
||||
const standaloneFields = visibleFields.filter(f => f.standalone)
|
||||
|
||||
// 无数据时保持信息条挂载 (切股/首次加载): 只留 symbol+名称+小 spinner 作为加载态,
|
||||
// 不渲染假占位值; 数据到位后价格/市值等原位填充, 避免整行消失造成布局跳动。
|
||||
// 同时按字段配置预留与完整态相同的行数, 切股瞬间弹窗整体高度不塌陷 (不抖动)。
|
||||
if (rows.length === 0) {
|
||||
const reserveLines = (inlineFields.length > 0 ? 1 : 0) + standaloneFields.length
|
||||
return (
|
||||
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
|
||||
{/* 首行 min-h-7 对齐完整态的 text-lg 价格行高, 加载中不整体变矮 */}
|
||||
<div className="flex min-h-7 items-baseline gap-x-3 flex-wrap">
|
||||
<span className="text-foreground font-bold text-sm tracking-wide">{symbol}</span>
|
||||
{name && <span className="text-secondary font-medium">{name}</span>}
|
||||
<span className="ml-auto self-center text-muted">
|
||||
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
|
||||
</span>
|
||||
</div>
|
||||
{Array.from({ length: reserveLines }).map((_, i) => (
|
||||
<div key={i} className="h-4" />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const latest = rows[rows.length - 1]
|
||||
const prev = rows.length >= 2 ? rows[rows.length - 2] : null
|
||||
@@ -183,11 +208,6 @@ export function StockInfoBar({
|
||||
}
|
||||
}
|
||||
|
||||
const visibleFields = fields.filter(f => f.visible)
|
||||
// 按是否单独显示分组:普通列共一行,standalone 列各占一行
|
||||
const inlineFields = visibleFields.filter(f => !f.standalone)
|
||||
const standaloneFields = visibleFields.filter(f => f.standalone)
|
||||
|
||||
// 渲染单个字段(builtin / ext 通用)
|
||||
const renderField = (f: ColumnConfig): ReactNode => {
|
||||
if (f.source.type === 'ext') {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { X } from 'lucide-react'
|
||||
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
|
||||
import { api, type KlineRow, type FinancialMetricRecord } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { klineDailyQueryOptions } from '@/lib/kline'
|
||||
import { StockInfoBar } from '@/components/StockInfoBar'
|
||||
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
|
||||
import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDailyKChart'
|
||||
import { StockIntradayChart } from '@/components/StockIntradayChart'
|
||||
import { useFinancialMetrics } from '@/lib/useFinancials'
|
||||
import { financialMetricsQueryOptions, useFinancialMetrics } from '@/lib/useFinancials'
|
||||
import { useCapabilities } from '@/lib/useSharedQueries'
|
||||
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
|
||||
import {
|
||||
@@ -40,6 +43,8 @@ interface Props {
|
||||
refetchIntervalMs?: number
|
||||
/** 只渲染信息条, 隐藏图表 (用于分时 tab 共享信息条) */
|
||||
infoBarOnly?: boolean
|
||||
/** 邻近预取目标 (切股导航的左右邻股): 提前拉取其日K/财务/分时缓存, 切换瞬间免 loading */
|
||||
prefetchSymbols?: string[]
|
||||
}
|
||||
|
||||
export { getDefaultRange }
|
||||
@@ -64,11 +69,11 @@ export function StockPanel({
|
||||
watchlistPending,
|
||||
refetchIntervalMs,
|
||||
infoBarOnly = false,
|
||||
prefetchSymbols,
|
||||
}: Props) {
|
||||
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null)
|
||||
const [intradayDismissed, setIntradayDismissed] = useState(false)
|
||||
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
|
||||
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
|
||||
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
|
||||
const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields])
|
||||
@@ -91,27 +96,62 @@ export function StockPanel({
|
||||
|
||||
const dateRange = externalDateRange ?? getDefaultRange()
|
||||
|
||||
// 日K查询由本组件持有 (与 StockDailyKChart 共享同一 cache key/配置, 只发一次请求)。
|
||||
// 信息条直接读 query data: 切股到已预取邻股时首帧即有数据, 配合 StockInfoBar 加载态占位,
|
||||
// 弹窗整体高度在切换瞬间不塌陷 (不抖动)。
|
||||
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol })
|
||||
const rawRows: KlineRow[] = kline.data?.rows ?? []
|
||||
// OHLC 视图用于日期选中/昨收价推导 (与图表侧同口径)
|
||||
const rows = useMemo(() => toOHLC(rawRows), [rawRows])
|
||||
const stockInfo = kline.data?.stock_info
|
||||
const name = kline.data?.name
|
||||
|
||||
const handleDateClick = useCallback((date: string) => {
|
||||
setSelectedDate(date)
|
||||
setIntradayDismissed(false)
|
||||
onSelectDate?.(date)
|
||||
}, [onSelectDate])
|
||||
|
||||
const rows = dailyResult?.rows ?? []
|
||||
const stockInfo = dailyResult?.stockInfo
|
||||
const rawRows: KlineRow[] = dailyResult?.rawRows ?? []
|
||||
// 邻近预取: 对切股导航的左右邻股提前拉取缓存, 切股瞬间免 loading。
|
||||
// 日K/分时预取 staleTime 30s 防来回切换重复请求; 成为当前股后 useQuery(staleTime=0) 立即后台刷新,
|
||||
// SSE 也只按焦点股精准失效, 实时性不受影响。财务指标与正式查询同 staleTime, 5min 内不重复拉取。
|
||||
// prefetchKey 按内容 join: 自选页 navList 随行情 tick 重建但邻股集合通常不变, 避免 effect 每次 tick 重跑。
|
||||
const qc = useQueryClient()
|
||||
const prefetchKey = prefetchSymbols?.join(',') ?? ''
|
||||
// 守卫快速连续切股: 旧链路上异步回来的日K不再级联预取 (避免串股/浪费)
|
||||
const prefetchTickRef = useRef('')
|
||||
useEffect(() => {
|
||||
if (!prefetchKey) return
|
||||
prefetchTickRef.current = prefetchKey
|
||||
const tick = prefetchKey
|
||||
for (const s of prefetchKey.split(',')) {
|
||||
if (s === symbol) continue
|
||||
if (hasFinanceField && hasFinancialCap) {
|
||||
qc.prefetchQuery(financialMetricsQueryOptions(s))
|
||||
}
|
||||
// 日K用 fetchQuery (返回数据) 以便级联预取分时; 邻股预取失败静默, 不影响切股。
|
||||
void qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 })
|
||||
.then((res) => {
|
||||
if (prefetchTickRef.current !== tick) return
|
||||
// 日K到货后级联预取其默认选中日的分时数据: 日K视图并排展示分时图(默认选中最后交易日)。
|
||||
const lastDate = res?.rows?.at(-1)?.date
|
||||
if (lastDate) {
|
||||
const d = String(lastDate).slice(0, 10)
|
||||
qc.prefetchQuery({ queryKey: QK.klineMinute(s, d), queryFn: () => api.klineMinute(s, d), staleTime: 30_000 })
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, qc])
|
||||
|
||||
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
|
||||
// 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存,
|
||||
// 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据,
|
||||
// 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。
|
||||
// 日K信息直接读 query data (切股到已预取邻股首帧即有), 无需清空或门控。
|
||||
const prevSymbol = useRef<string | null>(symbol)
|
||||
useEffect(() => {
|
||||
if (prevSymbol.current === symbol) return
|
||||
prevSymbol.current = symbol
|
||||
setSelectedDate(null)
|
||||
setLinkedPrice(null)
|
||||
setDailyResult(null)
|
||||
}, [symbol])
|
||||
|
||||
// 当分时开启、无选中日期时,自动选中最新日期
|
||||
@@ -136,7 +176,7 @@ export function StockPanel({
|
||||
<div className={className}>
|
||||
<StockInfoBar
|
||||
symbol={symbol}
|
||||
name={dailyResult?.name}
|
||||
name={name}
|
||||
stockInfo={stockInfo}
|
||||
rows={rawRows}
|
||||
fields={fields}
|
||||
@@ -164,7 +204,6 @@ export function StockPanel({
|
||||
linkedPrice={linkedPrice}
|
||||
onDateClick={handleDateClick}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
onDataChange={setDailyResult}
|
||||
visibleBars={showIntraday ? 40 : 60}
|
||||
extColumns={extColumns}
|
||||
refetchIntervalMs={refetchIntervalMs}
|
||||
|
||||
@@ -203,6 +203,15 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList
|
||||
return true
|
||||
}, [navList, navIdx, navTotal])
|
||||
|
||||
// 邻近预取目标: 当前股左右相邻两只 (首↔尾循环), 交由 StockPanel 提前拉取日K/财务/分时缓存
|
||||
const prefetchSymbols = useMemo(() => {
|
||||
if (!navEnabled) return []
|
||||
return [
|
||||
navList[wrapNavIndex(navIdx, -1, navTotal)].symbol,
|
||||
navList[wrapNavIndex(navIdx, 1, navTotal)].symbol,
|
||||
]
|
||||
}, [navEnabled, navIdx, navTotal, navList])
|
||||
|
||||
// ESC 关闭 + 左右键切股
|
||||
useEffect(() => {
|
||||
if (!symbol) return
|
||||
@@ -590,6 +599,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList
|
||||
priceLines={monitorPriceLines}
|
||||
onPriceDoubleClick={openPriceAlert}
|
||||
refetchIntervalMs={intradayRefetchMs}
|
||||
prefetchSymbols={prefetchSymbols}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -597,6 +607,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList
|
||||
symbol={symbol}
|
||||
dateRange={dateRange}
|
||||
infoBarOnly
|
||||
prefetchSymbols={prefetchSymbols}
|
||||
/>
|
||||
<StockMultiDayIntradayChart
|
||||
symbol={symbol}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 日K查询配置 — klineDaily 的唯一权威 options。
|
||||
*
|
||||
* StockDailyKChart(图表) 与 StockPanel(信息条) 各自 useQuery 共享同一 cache key,
|
||||
* React Query 按 key 去重只发一次请求; 邻近预取 prefetchQuery 也复用本配置, 三处不会漂移。
|
||||
*
|
||||
* placeholderData 内置"仅同 symbol 占位"守卫: 改日期范围/扩展字段时旧数据可暂显(不闪),
|
||||
* 切股时不透传上一只股票的数据(不误显示)。
|
||||
*/
|
||||
import { api } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
|
||||
export function klineDailyQueryOptions(
|
||||
symbol: string,
|
||||
dateRange: { start: string; end: string },
|
||||
extColumns?: string,
|
||||
) {
|
||||
return {
|
||||
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
|
||||
queryFn: () => api.klineDaily(symbol, undefined, dateRange, extColumns),
|
||||
// 工厂无 TData 泛型, 参数用 any 以便 useQuery/prefetchQuery 共用
|
||||
placeholderData: (prev: any, prevQuery: any) => {
|
||||
const prevKey = prevQuery?.queryKey as readonly unknown[] | undefined
|
||||
return prevKey?.[1] === symbol ? prev : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -20,13 +20,16 @@ export function useFinancialStatus() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useFinancialMetrics(symbol?: string) {
|
||||
return useQuery({
|
||||
export function financialMetricsQueryOptions(symbol?: string) {
|
||||
return {
|
||||
queryKey: FINANCIAL_QK.metrics(symbol),
|
||||
queryFn: () => api.financialMetrics(symbol),
|
||||
enabled: !!symbol,
|
||||
staleTime: 300_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function useFinancialMetrics(symbol?: string) {
|
||||
return useQuery({ ...financialMetricsQueryOptions(symbol), enabled: !!symbol })
|
||||
}
|
||||
|
||||
export function useFinancialIncome(symbol?: string) {
|
||||
|
||||
Reference in New Issue
Block a user