From cda496ba996e5b1d4eabf0de927fd9ae6994eb0d Mon Sep 17 00:00:00 2001 From: 0112020179 Date: Tue, 8 Sep 2026 18:46:44 +0800 Subject: [PATCH] =?UTF-8?q?fix(frontend):=20=E5=BD=93=E5=89=8D=E8=82=A1?= =?UTF-8?q?=E7=A5=A8=E5=8A=A0=E8=BD=BD=E5=90=8E=E4=B8=B2=E8=A1=8C=E9=A2=84?= =?UTF-8?q?=E5=8F=96=E9=82=BB=E8=82=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/StockPanel.tsx | 50 +++++----- frontend/src/lib/neighborPrefetch.test.ts | 106 ++++++++++++++++++++++ frontend/src/lib/neighborPrefetch.ts | 35 +++++++ 3 files changed, 165 insertions(+), 26 deletions(-) create mode 100644 frontend/src/lib/neighborPrefetch.test.ts create mode 100644 frontend/src/lib/neighborPrefetch.ts diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index be6ecc0..8a33b5d 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -8,6 +8,7 @@ import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDai import { StockIntradayChart } from '@/components/StockIntradayChart' import { financialMetricsQueryOptions, useFinancialMetrics } from '@/lib/useFinancials' import { useCapabilities } from '@/lib/useSharedQueries' +import { scheduleNeighborPrefetch } from '@/lib/neighborPrefetch' import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick' import { loadInfoFields, @@ -102,7 +103,10 @@ export function StockPanel({ ) const financials = useFinancialMetrics(hasFinanceField && hasFinancialCap ? symbol : undefined) - const dateRange = externalDateRange ?? getDefaultRange() + const defaultDateRange = getDefaultRange() + const rangeStart = externalDateRange?.start ?? defaultDateRange.start + const rangeEnd = externalDateRange?.end ?? defaultDateRange.end + const dateRange = useMemo(() => ({ start: rangeStart, end: rangeEnd }), [rangeStart, rangeEnd]) // 日K查询由本组件持有 (与 StockDailyKChart 共享同一 cache key/配置, 只发一次请求)。 // 信息条直接读 query data: 切股到已预取邻股时首帧即有数据, 配合 StockInfoBar 加载态占位, @@ -120,41 +124,35 @@ export function StockPanel({ onSelectDate?.(date) }, [onSelectDate]) - // 邻近预取: 对切股导航的左右邻股提前拉取缓存, 切股瞬间免 loading。 + // 当前日K就绪后, 等可见查询空闲再串行预取邻股, 避免首屏争抢公网带宽。 // 日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 - for (const s of prefetchKey.split(',')) { + if (!symbol || !prefetchKey || !kline.isSuccess || kline.isPlaceholderData) return + const tasks: Array<() => Promise> = [] + for (const s of new Set(prefetchKey.split(',').filter(Boolean))) { if (s === symbol) continue + let lastDate: string | undefined + tasks.push(async () => { + const res = await qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 }) + const date = res?.rows?.at(-1)?.date + lastDate = date ? String(date).slice(0, 10) : undefined + }) + // 独立排队, 切股/关闭后的日K响应不会继续级联分钟请求。 + tasks.push(async () => { + if (lastDate) await qc.prefetchQuery({ ...klineMinuteQueryOptions(s, lastDate, true), staleTime: 30_000 }) + }) if (hasFinanceField && hasFinancialCap) { - qc.prefetchQuery(financialMetricsQueryOptions(s)) + tasks.push(() => qc.prefetchQuery(financialMetricsQueryOptions(s))) } - // 分时 tab 的多日分时 + 最新分时: 切股后分时图也免 loading (与日K并行预取) - qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 }) - // latest 当日分时同样 live=true: 预取与渲染同读实时源 (历史日期后端忽略 live) - qc.prefetchQuery({ ...klineMinuteQueryOptions(s, undefined, true), staleTime: 30_000 }) - // 日K用 fetchQuery (返回数据) 以便级联预取分时; 邻股预取失败静默, 不影响切股。 - void qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 }) - .then((res) => { - if (prefetchTickRef.current !== prefetchKey) return - // 日K到货后级联预取其默认选中日的分时数据: 日K视图并排展示分时图(默认选中最后交易日)。 - const lastDate = res?.rows?.at(-1)?.date - if (lastDate) { - const d = String(lastDate).slice(0, 10) - // 同上 live=true: 该日若为当日即命中实时源 (历史日期后端忽略 live) - qc.prefetchQuery({ ...klineMinuteQueryOptions(s, d, true), staleTime: 30_000 }) - } - }) - .catch(() => {}) + tasks.push(() => qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 })) + tasks.push(() => qc.prefetchQuery({ ...klineMinuteQueryOptions(s, undefined, true), staleTime: 30_000 })) } - }, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, intradayDays, qc]) + return scheduleNeighborPrefetch(qc, symbol, tasks) + }, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, intradayDays, qc, kline.isSuccess, kline.isPlaceholderData]) // symbol 变化时重置分时相关状态,避免切股后残留旧日期。 // 日K信息直接读 query data (切股到已预取邻股首帧即有), 无需清空或门控。 diff --git a/frontend/src/lib/neighborPrefetch.test.ts b/frontend/src/lib/neighborPrefetch.test.ts new file mode 100644 index 0000000..86fa65a --- /dev/null +++ b/frontend/src/lib/neighborPrefetch.test.ts @@ -0,0 +1,106 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { QueryClient, QueryObserver } from '@tanstack/react-query' +import { scheduleNeighborPrefetch } from './neighborPrefetch.ts' + +const tick = () => new Promise(resolve => setTimeout(resolve, 10)) +function deferred() { + let resolve!: () => void + const promise = new Promise(done => { resolve = done }) + return { promise, resolve } +} + +test('foreground requests finish before serial neighbor requests start', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }) + const foreground = deferred() + const neighbor = deferred() + const calls: string[] = [] + const observer = new QueryObserver(client, { + queryKey: ['kline-minute', 'current'], + queryFn: async () => { await foreground.promise; return [] }, + }) + const unsubscribe = observer.subscribe(() => {}) + const stop = scheduleNeighborPrefetch(client, 'current', [ + async () => { calls.push('first'); await neighbor.promise }, + async () => { calls.push('second') }, + ]) + try { + await tick() + assert.deepEqual(calls, []) + foreground.resolve() + await tick() + assert.deepEqual(calls, ['first']) + neighbor.resolve() + await tick() + assert.deepEqual(calls, ['first', 'second']) + } finally { stop(); unsubscribe(); client.clear() } +}) + +test('new foreground work pauses the queue; errors allow it to resume', async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }) + const neighbor = deferred() + const foreground = deferred() + const calls: string[] = [] + const stop = scheduleNeighborPrefetch(client, 'current', [ + async () => { calls.push('first'); await neighbor.promise }, + async () => { calls.push('second'); throw new Error('prefetch failed') }, + async () => { calls.push('third') }, + ]) + await tick() + const observer = new QueryObserver(client, { + queryKey: ['financials', 'metrics', 'current'], + queryFn: async () => { await foreground.promise; throw new Error('unavailable') }, + }) + const unsubscribe = observer.subscribe(() => {}) + try { + neighbor.resolve() + await tick() + assert.deepEqual(calls, ['first']) + foreground.resolve() + await tick(); await tick() + assert.deepEqual(calls, ['first', 'second', 'third']) + } finally { stop(); unsubscribe(); client.clear() } +}) + +test('switching or closing stops pending and chained work', async () => { + const client = new QueryClient() + const neighbor = deferred() + const calls: string[] = [] + const stop = scheduleNeighborPrefetch(client, 'current', [ + async () => { calls.push('first'); await neighbor.promise }, + async () => { calls.push('second') }, + ]) + await tick() + stop() + neighbor.resolve() + await tick() + assert.deepEqual(calls, ['first']) + const stopBeforeStart = scheduleNeighborPrefetch(client, 'next', [ + async () => { calls.push('obsolete') }, + ]) + stopBeforeStart() + await tick() + assert.deepEqual(calls, ['first']) + client.clear() +}) + +test('prefetch reuses a fresh cache entry without another network request', async () => { + const client = new QueryClient({ defaultOptions: { queries: { gcTime: Infinity } } }) + const key = ['kline', 'neighbor'] + client.setQueryData(key, { rows: [] }) + let requests = 0 + let completed = false + const stop = scheduleNeighborPrefetch(client, 'current', [ + () => client.prefetchQuery({ + queryKey: key, + staleTime: 30_000, + queryFn: async () => { requests++; return { rows: [] } }, + }), + async () => { completed = true }, + ]) + try { + await tick(); await tick() + assert.equal(completed, true) + assert.equal(requests, 0) + } finally { stop(); client.clear() } +}) diff --git a/frontend/src/lib/neighborPrefetch.ts b/frontend/src/lib/neighborPrefetch.ts new file mode 100644 index 0000000..10a5fc1 --- /dev/null +++ b/frontend/src/lib/neighborPrefetch.ts @@ -0,0 +1,35 @@ +import type { QueryClient } from '@tanstack/react-query' + +/** Yield to mounted foreground queries, including charts that mount after daily K arrives. */ +export function scheduleNeighborPrefetch( + client: QueryClient, + symbol: string, + tasks: Array<() => Promise>, +): () => void { + let stopped = false + let running = false + let next = 0 + let timer: ReturnType | undefined + + const schedule = () => { + if (stopped || running || timer !== undefined || next >= tasks.length) return + timer = setTimeout(() => { + timer = undefined + if (stopped || client.isFetching({ + predicate: query => query.isActive() && query.queryKey.includes(symbol), + })) return + running = true + void Promise.resolve().then(tasks[next++]).catch(() => {}).finally(() => { + running = false + schedule() + }) + }, 0) + } + const unsubscribe = client.getQueryCache().subscribe(schedule) + schedule() + return () => { + stopped = true + clearTimeout(timer) + unsubscribe() + } +}