mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
|
||||
@@ -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 ?? '') }}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user