diff --git a/frontend/src/components/StockIntradayChart.tsx b/frontend/src/components/StockIntradayChart.tsx index 6f014e9..7791be8 100644 --- a/frontend/src/components/StockIntradayChart.tsx +++ b/frontend/src/components/StockIntradayChart.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Loader2 } from 'lucide-react' import { api, type MinuteKlineRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { klineMinuteQueryOptions } from '@/lib/kline' import { EChartsIntraday } from '@/components/EChartsIntraday' interface Props { @@ -35,10 +36,9 @@ export function StockIntradayChart({ const [minuteDismissed, setMinuteDismissed] = useState(false) const minute = useQuery({ - queryKey: QK.klineMinute(symbol, date ?? ''), // 轮询上下文 (个股详情) 传 live: 当日盘中后端直接实时拉取最新K, // 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。 - queryFn: () => api.klineMinute(symbol, date ?? undefined, refetchIntervalMs != null), + ...klineMinuteQueryOptions(symbol, date ?? undefined, refetchIntervalMs != null), enabled: !!symbol && !!date, refetchInterval: refetchIntervalMs, }) diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx index 3024129..09281a3 100644 --- a/frontend/src/components/StockMultiDayIntradayChart.tsx +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Download, Loader2, RefreshCw } from 'lucide-react' import { api, type MinuteKlineSession } from '@/lib/api' import { QK } from '@/lib/queryKeys' +import { klineMinuteQueryOptions } from '@/lib/kline' import { toast } from '@/components/Toast' import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday' @@ -36,9 +37,8 @@ export function StockMultiDayIntradayChart({ previousQuery?.queryKey[1] === symbol ? previous : undefined, }) const latest = useQuery({ - queryKey: QK.klineMinute(symbol, ''), // live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢 - queryFn: () => api.klineMinute(symbol, undefined, true), + ...klineMinuteQueryOptions(symbol, undefined, true), enabled: !!symbol, refetchInterval: refetchIntervalMs, }) diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 165093d..355df1e 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -1,9 +1,8 @@ import { useEffect, useState, useCallback, useRef, useMemo } from 'react' import { useQuery, useQueryClient } from '@tanstack/react-query' import { X } from 'lucide-react' -import { api, type KlineRow, type FinancialMetricRecord } from '@/lib/api' -import { QK } from '@/lib/queryKeys' -import { klineDailyQueryOptions } from '@/lib/kline' +import { type KlineRow, type FinancialMetricRecord } from '@/lib/api' +import { klineDailyQueryOptions, klineMinuteQueryOptions } from '@/lib/kline' import { StockInfoBar } from '@/components/StockInfoBar' import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDailyKChart' import { StockIntradayChart } from '@/components/StockIntradayChart' @@ -123,7 +122,6 @@ export function StockPanel({ useEffect(() => { if (!prefetchKey) return prefetchTickRef.current = prefetchKey - const tick = prefetchKey for (const s of prefetchKey.split(',')) { if (s === symbol) continue if (hasFinanceField && hasFinancialCap) { @@ -132,12 +130,13 @@ export function StockPanel({ // 日K用 fetchQuery (返回数据) 以便级联预取分时; 邻股预取失败静默, 不影响切股。 void qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 }) .then((res) => { - if (prefetchTickRef.current !== tick) return + if (prefetchTickRef.current !== prefetchKey) 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 }) + // live=true: 若 d 为当日, 预取即命中实时源, 与实际渲染的 queryKey 同源 (历史日期后端忽略 live) + qc.prefetchQuery({ ...klineMinuteQueryOptions(s, d, true), staleTime: 30_000 }) } }) .catch(() => {}) diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 6315d0e..f298a39 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -218,6 +218,8 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList const handler = (e: KeyboardEvent) => { if (e.key === 'Escape' && !priceAlertDraft) { onCloseRef.current(); return } if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + // 点位监控弹窗打开时方向键不切股 (与 ESC 的 !priceAlertDraft 守卫同层级) + if (priceAlertDraft) return // 焦点在输入框/编辑器时方向键让位给光标/输入, 不切股 const t = e.target as HTMLElement | null if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return diff --git a/frontend/src/lib/kline.ts b/frontend/src/lib/kline.ts index c879fc8..c2375c4 100644 --- a/frontend/src/lib/kline.ts +++ b/frontend/src/lib/kline.ts @@ -25,3 +25,16 @@ export function klineDailyQueryOptions( }, } } + +/** + * 单日分时查询配置 — 与 klineDailyQueryOptions 同风格的单源 options (date 为空 = 最新日内)。 + * + * live 透传上游语义: 当日盘中传 true 后端直接实时拉取最新K (不被分钟增量落盘的本地分区拖慢); + * 历史日期后端自行忽略 live, 所以预取/多日图恒传 true 也不会影响历史读取。 + */ +export function klineMinuteQueryOptions(symbol: string, date?: string, live?: boolean) { + return { + queryKey: QK.klineMinute(symbol, date ?? ''), + queryFn: () => api.klineMinute(symbol, date ?? undefined, live), + } +} diff --git a/frontend/src/pages/ConceptAnalysis.tsx b/frontend/src/pages/ConceptAnalysis.tsx index 97dbf1b..99c855a 100644 --- a/frontend/src/pages/ConceptAnalysis.tsx +++ b/frontend/src/pages/ConceptAnalysis.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState, type ReactNode } from 'react' +import { useCallback, useMemo, useState, type ReactNode } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { AnimatePresence } from 'framer-motion' import { @@ -242,6 +242,11 @@ export function ConceptAnalysis() { const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') const [previewNavList, setPreviewNavList] = useState([]) + const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => { + setPreviewSymbol(symbol) + setPreviewName(name ?? '') + setPreviewNavList(navList ?? []) + }, []) const [showRps, setShowRps] = useState(false) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) @@ -395,7 +400,7 @@ export function ConceptAnalysis() { selectedKey={selected?.key ?? null} onSelect={setSelectedKey} activeSymbol={previewSymbol} - onStockClick={(sym, name, navList) => { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} + onStockClick={handleStockClick} /> {stats.length > 0 ? ( @@ -409,7 +414,7 @@ export function ConceptAnalysis() { onSort={setSortMode} onSelect={setSelectedKey} /> - { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} /> + ) : rowsQuery.isLoading ? (
正在计算概念强度...
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index c30061c..e418927 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -576,9 +576,10 @@ function stockListNav(rows: MarketSnapshotRow[]): NavItem[] { return toNavItems(rows.slice(0, 8)) } function rankNav(rank?: OverviewMarket['concept_rank']): NavItem[] { - return [...(rank?.leading ?? []), ...(rank?.lagging ?? [])] - .filter(r => r.leader?.symbol) - .map(r => ({ symbol: r.leader!.symbol!, name: r.leader!.name ?? undefined })) + const leaders = [...(rank?.leading ?? []), ...(rank?.lagging ?? [])] + .map(r => r.leader) + .filter((l): l is NonNullable & { symbol: string } => !!l?.symbol) + return toNavItems(leaders) } export function Dashboard() { @@ -935,7 +936,7 @@ export function Dashboard() { message: previewStock.alert.message, } : null} navList={previewStock?.navList} - onNavigate={(sym, n) => setPreviewStock(prev => prev ? { ...prev, symbol: sym, name: n, alert: prev.source === 'alert' ? undefined : prev.alert } : prev)} + onNavigate={(sym, n) => setPreviewStock(prev => prev ? { ...prev, symbol: sym, name: n, alert: undefined } : prev)} onClose={() => setPreviewStock(null)} /> (null) const [previewName, setPreviewName] = useState('') const [previewNavList, setPreviewNavList] = useState([]) + const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => { + setPreviewSymbol(symbol) + setPreviewName(name ?? '') + setPreviewNavList(navList ?? []) + }, []) const [showRps, setShowRps] = useState(false) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) @@ -448,7 +453,7 @@ export function IndustryAnalysis() { selectedKey={selected?.key ?? null} onSelect={setSelectedKey} activeSymbol={previewSymbol} - onStockClick={(sym, name, navList) => { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} + onStockClick={handleStockClick} /> {/* 热力图 */} @@ -473,7 +478,7 @@ export function IndustryAnalysis() { onSort={setSortMode} onSelect={setSelectedKey} /> - { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} /> + ) : rowsQuery.isLoading ? (
正在计算行业强度...
diff --git a/frontend/src/pages/LimitUpLadder.tsx b/frontend/src/pages/LimitUpLadder.tsx index 035ce7b..6e12ad2 100644 --- a/frontend/src/pages/LimitUpLadder.tsx +++ b/frontend/src/pages/LimitUpLadder.tsx @@ -1135,7 +1135,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, )}
- {sortLadderStocks(tier.stocks, { monitoredSymbols, sealMode, selectedTag, extFields }).map(s => ( + {tier.stocks.map(s => ( filterTiers(rawTiers, filterKeys, extFields.bf), [rawTiers, filterKeys, extFields.bf]) const displayDate = data?.as_of ?? asOf - // 切股导航列表: 各梯队按展示同款排序展平 (监控优先 → 状态 → 封单量) + // 单源: 梯队按展示同款过滤+排序一次 (监控优先 → 状态 → 封单量), + // 卡片渲染(TierGroup)与切股导航(ladderNavItems)共用, 避免每 tick 二次排序。 + const resolvedExtFields = useMemo( + () => resolveExtFields(extFields, showConcept, showIndustry), + [extFields, showConcept, showIndustry], + ) + const sortedTiers = useMemo( + () => tiers.map(t => ({ ...t, stocks: sortLadderStocks(t.stocks, { monitoredSymbols, sealMode, selectedTag, extFields: resolvedExtFields }) })), + [tiers, monitoredSymbols, sealMode, selectedTag, resolvedExtFields], + ) + + // 切股导航列表: 由 sortedTiers 展平 (顺序 = 卡片展示顺序) const ladderNavItems = useMemo( - () => toNavItems(tiers.flatMap(t => sortLadderStocks(t.stocks, { - monitoredSymbols, - sealMode, - selectedTag, - extFields: resolveExtFields(extFields, showConcept, showIndustry), - }))), - [tiers, monitoredSymbols, sealMode, selectedTag, extFields, showConcept, showIndustry], + () => toNavItems(sortedTiers.flatMap(t => t.stocks)), + [sortedTiers], ) const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => { @@ -1742,7 +1748,7 @@ export function LimitUpLadder() { - {tiers.map(t => ( + {sortedTiers.map(t => ( = 1 || t.count <= 8} - extFields={resolveExtFields(extFields, showConcept, showIndustry)} + extFields={resolvedExtFields} filterKeys={filterKeys} bf={extFields.bf} onStockClick={handleStockClick} diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index dd7beb9..d6f8905 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -719,7 +719,7 @@ function RulesList({ rulesQuery, onEdit }: { const rulesNavItems = useMemo( () => rules .filter(r => r.scope === 'symbols' && r.symbols.length > 0) - .map(r => ({ symbol: r.symbols[0], name: symbolNames[r.symbols[0]] ?? undefined }) as NavItem), + .map(r => ({ symbol: r.symbols[0], name: symbolNames[r.symbols[0]] ?? undefined })), [rules, symbolNames], ) diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index bc3f27d..af6f19e 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -13,7 +13,7 @@ import { storage } from '@/lib/storage' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { DatePicker } from '@/components/DatePicker' -import { StockPreviewDialog, toNavItems } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, type NavItem } from '@/components/StockPreviewDialog' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { useStrategyPool } from '@/lib/useStrategyPool' import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard' @@ -50,7 +50,12 @@ export function Screener() { const [batchMsg, setBatchMsg] = useState('') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') - const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, []) + const [previewNavList, setPreviewNavList] = useState([]) + const closePreview = useCallback(() => { + setPreviewSymbol(null) + setPreviewName('') + setPreviewNavList([]) + }, []) const [settingsStrategyId, setSettingsStrategyId] = useState(null) const [showPoolDialog, setShowPoolDialog] = useState(false) const [showBuilder, setShowBuilder] = useState(false) @@ -405,9 +410,6 @@ export function Screener() { return mainRows }, [showAll, allRows, filteredRows, filter, activeStrategy, strategyLimits, expiredRows, sort, sortRows, columns]) - // 切股导航列表: 按当前展示顺序 (含灰色失效行) - const previewNavItems = useMemo(() => toNavItems(displayRows), [displayRows]) - // 日k列是否启用 → 决定是否加载批量 kline 数据 const candleColumn = useMemo(() => columns.find(c => c.source.type === 'builtin' && c.source.key === 'candle' && c.visible), @@ -987,7 +989,7 @@ export function Screener() { activeStrategy={activeStrategy} activeSymbol={previewSymbol} watchlistSet={watchlistSet} - onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name ?? '') }} + onPreview={(symbol, name, navList) => { setPreviewSymbol(symbol); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} onAddToWatchlist={(symbol, groupId) => toggleWatchlist.mutate({ symbol, action: 'add', groupId })} onRemoveFromWatchlist={symbol => toggleWatchlist.mutate({ symbol, action: 'remove' })} watchlistPending={toggleWatchlist.isPending} @@ -1039,7 +1041,7 @@ export function Screener() { symbol={previewSymbol} name={previewName} onClose={closePreview} - navList={previewNavItems} + navList={previewNavList} onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index f4b1caa..7211032 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -1259,10 +1259,11 @@ export function Watchlist() { [filteredRows, sortRows, columns], ) - // 切股导航列表: 按列表当前展示顺序 (与 sortedRows 一致, 排序/筛选后行序随之变化) + // 切股导航列表: 按列表当前展示顺序 (与 sortedRows 一致, 排序/筛选后行序随之变化)。 + // 弹窗未打开时跳过构建 — sortedRows 随行情 tick 重建, 避免无谓分配。 const previewNavItems = useMemo( - () => toNavItems(sortedRows), - [sortedRows], + () => previewSymbol ? toNavItems(sortedRows) : [], + [previewSymbol, sortedRows], ) const cardColumns = useCardColumnCount()