From ef66877529961e3ca25e5b213d597bcea2bac07e Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:14:11 +0800 Subject: [PATCH] feat(kline): prefetch neighbor stocks and stabilize dialog height MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 切股瞬间免 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 --- frontend/src/components/StockDailyKChart.tsx | 39 ++--------- frontend/src/components/StockInfoBar.tsx | 32 +++++++-- frontend/src/components/StockPanel.tsx | 65 +++++++++++++++---- .../src/components/StockPreviewDialog.tsx | 11 ++++ frontend/src/lib/kline.ts | 27 ++++++++ frontend/src/lib/useFinancials.ts | 11 ++-- 6 files changed, 129 insertions(+), 56 deletions(-) create mode 100644 frontend/src/lib/kline.ts diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index 495ecc4..f456131 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -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 ( diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx index 1e2d6c8..84737cf 100644 --- a/frontend/src/components/StockInfoBar.tsx +++ b/frontend/src/components/StockInfoBar.tsx @@ -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 ( +
+ {/* 首行 min-h-7 对齐完整态的 text-lg 价格行高, 加载中不整体变矮 */} +
+ {symbol} + {name && {name}} + + + +
+ {Array.from({ length: reserveLines }).map((_, i) => ( +
+ ))} +
+ ) + } 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') { diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 04c9544..165093d 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -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(null) const [selectedDate, setSelectedDate] = useState(null) const [intradayDismissed, setIntradayDismissed] = useState(false) - const [dailyResult, setDailyResult] = useState(null) // 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据 const [fields, setFields] = useState(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(symbol) useEffect(() => { if (prevSymbol.current === symbol) return prevSymbol.current = symbol setSelectedDate(null) setLinkedPrice(null) - setDailyResult(null) }, [symbol]) // 当分时开启、无选中日期时,自动选中最新日期 @@ -136,7 +176,7 @@ export function StockPanel({
{ + 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} /> 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 + }, + } +} diff --git a/frontend/src/lib/useFinancials.ts b/frontend/src/lib/useFinancials.ts index 5c11e20..154416d 100644 --- a/frontend/src/lib/useFinancials.ts +++ b/frontend/src/lib/useFinancials.ts @@ -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) {