refactor(kline): simplify after review — single-source queries, sort, and nav lists

审查后清理, 无行为变更:

- lib/kline.ts: 新增 klineMinuteQueryOptions, StockIntradayChart/StockMultiDayIntradayChart/
  StockPanel 邻股预取共用 (与 klineDailyQueryOptions 同风格单源)
- LimitUpLadder: sortLadderStocks 每 tick 只跑一次 (sortedTiers memo 供 TierGroup 与
  ladderNavItems 共用), resolveExtFields 提升为 memo (原来算 4 次)
- Dashboard: rankNav 复用 toNavItems 去掉 ! 断言; onNavigate 死条件简化为 alert: undefined
- Screener: 消费 ScreenerTable 传入的 navList (成员弹窗导航不再被丢弃), 移除重复 memo
- Watchlist: nav 列表仅在弹窗打开时构建 (sortedRows 随行情 tick 重建)
- ConceptAnalysis/IndustryAnalysis: 重复的 onStockClick 内联 lambda 抽为 handleStockClick
- StockPreviewDialog: 方向键与 ESC 同层级守卫 priceAlertDraft (点位弹窗打开时不切股)
- StockPanel: 去掉冗余 tick 别名; Monitor: 去掉冗余 as NavItem 断言

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
richard
2026-09-02 13:14:14 +08:00
co-authored by Claude
parent 3258325daa
commit 7038c7d627
12 changed files with 78 additions and 44 deletions
@@ -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,
})
@@ -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,
})
+5 -6
View File
@@ -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(() => {})
@@ -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
+13
View File
@@ -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),
}
}
+8 -3
View File
@@ -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<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
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}
/>
<ConceptFocus stat={selected} activeSymbol={previewSymbol} onStockClick={(sym, name, navList) => { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} />
<ConceptFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
+5 -4
View File
@@ -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<typeof l> & { 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)}
/>
<DimensionMembersDialog
+8 -3
View File
@@ -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 {
@@ -277,6 +277,11 @@ export function IndustryAnalysis() {
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
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}
/>
<IndustryFocus stat={selected} activeSymbol={previewSymbol} onStockClick={(sym, name, navList) => { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} />
<IndustryFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
+19 -13
View File
@@ -1135,7 +1135,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 px-3 pb-3">
{sortLadderStocks(tier.stocks, { monitoredSymbols, sealMode, selectedTag, extFields }).map(s => (
{tier.stocks.map(s => (
<StockCard
key={`${s.symbol}-${s.status}`}
stock={s}
@@ -1550,15 +1550,21 @@ export function LimitUpLadder() {
const tiers = useMemo(() => 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() {
<TagStats
title="概念分布"
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
extFields={resolvedExtFields}
fieldKey="concept"
color={{ text: [250, 204, 21], textLight: [161, 98, 7], bg: [234, 179, 8] }}
selectedTag={selectedTag}
@@ -1756,7 +1762,7 @@ export function LimitUpLadder() {
<TagStats
title="行业分布"
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
extFields={resolvedExtFields}
fieldKey="industry"
color={{ text: [96, 165, 250], textLight: [29, 78, 216], bg: [59, 130, 246] }}
selectedTag={selectedTag}
@@ -1768,12 +1774,12 @@ export function LimitUpLadder() {
{/* 梯队列表 */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
{tiers.map(t => (
{sortedTiers.map(t => (
<TierGroup
key={t.boards}
tier={t}
defaultOpen={t.boards >= 1 || t.count <= 8}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
extFields={resolvedExtFields}
filterKeys={filterKeys}
bf={extFields.bf}
onStockClick={handleStockClick}
+1 -1
View File
@@ -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],
)
+9 -7
View File
@@ -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<string>('')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const closePreview = useCallback(() => {
setPreviewSymbol(null)
setPreviewName('')
setPreviewNavList([])
}, [])
const [settingsStrategyId, setSettingsStrategyId] = useState<string | null>(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 ?? '') }}
/>
+4 -3
View File
@@ -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()