From 571e828529d725893028188227dfc8c2765107f9 Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:07:42 +0800 Subject: [PATCH 01/12] feat(kline): support in-dialog stock switching with arrow keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit K线弹窗支持在来源榜单内切股: 顶栏 ◀ n/N ▶ 按钮 + ←/→ 方向键(输入框/编辑器内让位), 首↔尾循环弱提示, 并给来源页当前预览行加高亮。 - StockPreviewDialog: 新增 navList/onNavigate props, 导出 NavItem/toNavItems; 合并 ESC 与方向键监听; wrapMsg 弱提示浮层 - 9 个来源页面(自选/监控/选股/概念/行业/看板/连板/成分)构建 navList 并传 activeSymbol Co-Authored-By: Claude --- .../src/components/DimensionMembersDialog.tsx | 5 +- .../src/components/StockPreviewDialog.tsx | 114 +++++++++++++++- .../src/components/screener/ScreenerTable.tsx | 24 ++-- frontend/src/pages/ConceptAnalysis.tsx | 40 ++++-- frontend/src/pages/Dashboard.tsx | 90 ++++++++---- frontend/src/pages/IndustryAnalysis.tsx | 40 ++++-- frontend/src/pages/LimitUpLadder.tsx | 129 +++++++++++------- frontend/src/pages/Monitor.tsx | 42 +++++- frontend/src/pages/Screener.tsx | 10 +- frontend/src/pages/Watchlist.tsx | 19 ++- 10 files changed, 379 insertions(+), 134 deletions(-) diff --git a/frontend/src/components/DimensionMembersDialog.tsx b/frontend/src/components/DimensionMembersDialog.tsx index d67ef6f..95db97b 100644 --- a/frontend/src/components/DimensionMembersDialog.tsx +++ b/frontend/src/components/DimensionMembersDialog.tsx @@ -13,6 +13,7 @@ import { import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react' import { Modal } from '@/components/Modal' import { boardTag } from '@/components/stock-table/primitives' +import { toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format' @@ -39,7 +40,7 @@ export function dimensionKindForSourceField(sourceField: string): DimensionKind interface Props { target: DimensionMembersTarget | null onClose: () => void - onStockClick?: (symbol: string, name?: string) => void + onStockClick?: (symbol: string, name?: string, navList?: NavItem[]) => void } interface ResolvedSource { @@ -279,7 +280,7 @@ function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit

onStockClick?.(row.symbol, row.name)} + onClick={() => onStockClick?.(row.symbol, row.name, toNavItems(visibleRows))} disabled={!onStockClick} className="absolute left-0 top-0 grid min-h-[54px] w-full grid-cols-[minmax(132px,1fr)_74px_74px_18px] items-center border-b border-border/60 px-4 text-left text-xs transition-colors hover:bg-elevated/50 disabled:cursor-default md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]" style={{ transform: `translateY(${virtualRow.start}px)` }} diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 606af2b..42f5501 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -1,7 +1,7 @@ -import { useState, useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' -import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity } from 'lucide-react' +import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity, ChevronLeft, ChevronRight } from 'lucide-react' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { cn } from '@/lib/cn' @@ -32,6 +32,23 @@ interface Props { signals?: string[] message?: string } | null + /** 有序候选列表: 提供后支持左右键/顶栏按钮切股, 标题栏显示 n/N */ + navList?: NavItem[] + /** 切股回调: 收到目标 symbol/name, 由调用方更新预览状态 */ + onNavigate?: (symbol: string, name?: string) => void +} + +/** 切股导航列表项 */ +export interface NavItem { symbol: string; name?: string } + +/** 把 symbol+name 的列表转成切股导航列表项 (统一 name 归一化为 undefined, 免去各处重复 map + as 断言) */ +export function toNavItems(xs: T[]): NavItem[] { + return xs.map(x => ({ symbol: x.symbol, name: x.name ?? undefined })) +} + +/** 首↔尾循环的索引换算: go(delta) 与 邻近预取 共用, 保证换行规则单源 */ +function wrapNavIndex(navIdx: number, delta: number, navTotal: number): number { + return (navIdx + delta + navTotal) % navTotal } // ===== 板块标识(与 Screener 列表一致)===== @@ -79,7 +96,7 @@ function fmtAbnormalCalcTime(asofSec: number): string { return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` } -export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props) { +export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList: navListSource, onNavigate }: Props) { const [view, setView] = useState('daily') const [intradayDays, setIntradayDays] = useState(loadIntradayDays) const [dateRange, setDateRange] = useState(getDefaultRange) @@ -137,15 +154,59 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props }, }) - // ESC 关闭 + // ===== 切股导航 ===== + const navList = useMemo(() => navListSource ?? [], [navListSource]) + + // 当前 symbol 在 navList 中的位置 (不在列表则为 -1, 此时不显示计数/按钮) + const navIdx = navList.findIndex(n => n.symbol === symbol) + const navTotal = navList.length + const navEnabled = navTotal >= 2 && navIdx >= 0 + + // 首↔尾循环的弱提示 (自显 ~1.5s, 不引全局 Toast) + const [wrapMsg, setWrapMsg] = useState(null) + const wrapTimer = useRef(null) + useEffect(() => { + return () => { if (wrapTimer.current) window.clearTimeout(wrapTimer.current) } + }, []) + + // 父级 onNavigate/onClose 多为内联 lambda, 用最新值 ref 承接, 避免每次父渲染重建 go/键盘监听 + const onNavigateRef = useRef(onNavigate) + onNavigateRef.current = onNavigate + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + + // 前后切股: 返回是否真正导航 (供键盘判断是否要 preventDefault) + const go = useCallback((delta: 1 | -1): boolean => { + if (!navEnabled) return false + const nextIdx = wrapNavIndex(navIdx, delta, navTotal) + const wrapped = nextIdx === (delta === 1 ? 0 : navTotal - 1) + if (wrapped) { + // 提示词描述切股后的落点 (而非起点) + setWrapMsg(delta === 1 ? '已到榜首' : '已到末尾') + if (wrapTimer.current) window.clearTimeout(wrapTimer.current) + wrapTimer.current = window.setTimeout(() => setWrapMsg(null), 1500) + } + const next = navList[nextIdx] + onNavigateRef.current?.(next.symbol, next.name) + return true + }, [navList, navIdx, navTotal]) + + // ESC 关闭 + 左右键切股 useEffect(() => { if (!symbol) return const handler = (e: KeyboardEvent) => { - if (e.key === 'Escape' && !priceAlertDraft) onClose() + if (e.key === 'Escape' && !priceAlertDraft) { onCloseRef.current(); return } + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + // 焦点在输入框/编辑器时方向键让位给光标/输入, 不切股 + const t = e.target as HTMLElement | null + if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return + if (showMonitorEditor) return + if (go(e.key === 'ArrowRight' ? 1 : -1)) e.preventDefault() + } } document.addEventListener('keydown', handler) return () => document.removeEventListener('keydown', handler) - }, [symbol, onClose, priceAlertDraft]) + }, [symbol, go, showMonitorEditor, priceAlertDraft]) useEffect(() => { if (symbol) setView('daily') @@ -239,6 +300,32 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props })()} {symbol} {name && {name}} + + {/* 切股导航: 上一只 / n·N / 下一只 */} + {navEnabled && ( + <> + | + + + {navIdx + 1} / {navTotal} + + + + )}

@@ -546,6 +633,21 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props )} + + {/* 首↔尾循环弱提示 */} + + {wrapMsg && ( + + {wrapMsg} + + )} +
)} diff --git a/frontend/src/components/screener/ScreenerTable.tsx b/frontend/src/components/screener/ScreenerTable.tsx index 09439c7..e908da6 100644 --- a/frontend/src/components/screener/ScreenerTable.tsx +++ b/frontend/src/components/screener/ScreenerTable.tsx @@ -22,6 +22,8 @@ import { dimensionKindForSourceField, type DimensionMembersTarget, } from '@/components/DimensionMembersDialog' +import { toNavItems, type NavItem } from '@/components/StockPreviewDialog' +import { cn } from '@/lib/cn' interface ScreenerTableProps { rows: any[] @@ -30,7 +32,7 @@ interface ScreenerTableProps { symbolStrategyMap: Map activeStrategy: string | null watchlistSet: Set - onPreview: (symbol: string, name: string) => void + onPreview: (symbol: string, name?: string, navList?: NavItem[]) => void onAddToWatchlist: (symbol: string, groupId: string | null) => void onRemoveFromWatchlist: (symbol: string) => void watchlistPending: boolean @@ -57,6 +59,8 @@ interface ScreenerTableProps { /** 表头排序(受控,由 Screener.tsx 传入) */ sort?: SortState | null onSortToggle?: (colId: string) => void + /** 正在 K 线弹窗预览中的 symbol → 高亮该行 */ + activeSymbol?: string | null } /** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。 @@ -161,7 +165,7 @@ export function ScreenerTable({ minuteData = {}, intradayChartVisible = true, onToggleIntradayChart, intradayAutoRefresh = false, onRefreshIntraday, intradayRefreshing = false, strategyTagsExpanded = false, onToggleStrategyTags, - sort, onSortToggle, + sort, onSortToggle, activeSymbol, }: ScreenerTableProps) { const [expandedCells, setExpandedCells] = useState>(new Set()) const [dimensionTarget, setDimensionTarget] = useState(null) @@ -244,7 +248,7 @@ export function ScreenerTable({
) : rowsQuery.isLoading ? (
正在计算概念强度...
@@ -433,7 +435,9 @@ export function ConceptAnalysis() { { setPreviewSymbol(null); setPreviewName('') }} + onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }} + navList={previewNavList} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> )} @@ -509,17 +513,19 @@ function MarketPulse({ selectedKey, onSelect, onStockClick, + activeSymbol, }: { leading: ConceptStat[] falling: ConceptStat[] selectedKey: string | null onSelect: (key: string) => void - onStockClick: (symbol: string, name?: string) => void + onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void + activeSymbol: string | null }) { return (
- - + +
) } @@ -531,13 +537,15 @@ function PulseList({ selectedKey, onSelect, onStockClick, + activeSymbol, }: { title: string items: ConceptStat[] mode: 'up' | 'down' selectedKey: string | null onSelect: (key: string) => void - onStockClick: (symbol: string, name?: string) => void + onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void + activeSymbol: string | null }) { const toneText = mode === 'up' ? 'text-bull' : 'text-bear' const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20' @@ -556,7 +564,8 @@ function PulseList({
{items.map((item, idx) => { const active = selectedKey === item.key - const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3) + const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore) + const leaders = sortedStocks.slice(0, 3) const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0 const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0 const flatPct = Math.max(0, 100 - upPct - downPct) @@ -597,7 +606,7 @@ function PulseList({ {Array.from({ length: 3 }).map((_, i) => { const stock = leaders[i] return stock ? ( - { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}> + { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}> {stock.name || stock.symbol} @@ -674,10 +683,11 @@ function ConceptRail({ ) } -function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string) => void }) { +function ConceptFocus({ stat, onStockClick, activeSymbol }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) { if (!stat) return null const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS) const topLeaders = stocks.slice(0, 3) + const focusNav: NavItem[] = toNavItems(stocks) return (
@@ -706,7 +716,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc
- + onStockClick(sym, name, focusNav)} />
@@ -726,7 +736,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc {stocks.map((s, idx) => ( - onStockClick(s.symbol, s.name || undefined)}> + onStockClick(s.symbol, s.name || undefined, focusNav)}> {idx + 1}
{s.name || '—'}
@@ -757,7 +767,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st return
{label}
{value}
} -function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) { +function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) { if (!stocks.length) return
暂无龙头候选
return (
@@ -767,7 +777,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
{stocks.map((stock, idx) => ( -
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}> +
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
{idx === 0 ? '主龙头' : `辅龙 ${idx}`} {stock.leaderScore.toFixed(0)} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 702968a..c30061c 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -10,7 +10,7 @@ import { fmtBigNum, fmtPct } from '@/lib/format' import { DimensionMembersDialog, dimensionKindForSourceField, type DimensionMembersTarget } from '@/components/DimensionMembersDialog' import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { SettingsModal } from '@/components/data/SettingsModal' import { useAdjFactorSyncGate } from '@/components/AdjFactorSyncGate' import { STAGE_LABELS } from '@/components/data/ActiveJobCard' @@ -98,7 +98,10 @@ const _SEVERITY_BAR: Record = { info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger', } -function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => void }) { +function MonitorWidget({ onStockClick, activeSymbol }: { + onStockClick: (event: AlertEvent, navList?: NavItem[]) => void + activeSymbol?: string +}) { const navigate = useNavigate() const alerts = useQuery({ queryKey: ['alerts', ''], @@ -106,6 +109,8 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => refetchInterval: 10000, }) const events: AlertEvent[] = alerts.data?.alerts ?? [] + // 切股导航列表: 有 symbol 的触发记录 + const alertNav = toNavItems(events.filter((ev): ev is AlertEvent & { symbol: string } => !!ev.symbol)) if (events.length === 0) { return ( @@ -129,13 +134,13 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => initial={{ opacity: 0, y: -8, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }} - className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors" + className={`relative overflow-hidden rounded-md border pl-2.5 pr-2 py-1.5 transition-colors ${ev.symbol && ev.symbol === activeSymbol ? 'border-accent/40 bg-accent/5' : 'border-border/40 bg-surface/60 hover:border-border hover:bg-surface'}`} >
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
@@ -904,6 +934,8 @@ export function Dashboard() { signals: previewStock.alert.signals, 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)} onClose={() => setPreviewStock(null)} /> ('heat') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + const [previewNavList, setPreviewNavList] = useState([]) const [showRps, setShowRps] = useState(false) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) @@ -446,7 +447,8 @@ export function IndustryAnalysis() { falling={falling} selectedKey={selected?.key ?? null} onSelect={setSelectedKey} - onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} + activeSymbol={previewSymbol} + onStockClick={(sym, name, navList) => { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} /> {/* 热力图 */} @@ -471,7 +473,7 @@ export function IndustryAnalysis() { onSort={setSortMode} onSelect={setSelectedKey} /> - { setPreviewSymbol(sym); setPreviewName(name ?? '') }} /> + { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} /> ) : rowsQuery.isLoading ? (
正在计算行业强度...
@@ -497,7 +499,9 @@ export function IndustryAnalysis() { { setPreviewSymbol(null); setPreviewName('') }} + onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }} + navList={previewNavList} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> )} {showRps && setShowRps(false)} kind="industry" />} @@ -574,17 +578,19 @@ function MarketPulse({ selectedKey, onSelect, onStockClick, + activeSymbol, }: { leading: IndustryStat[] falling: IndustryStat[] selectedKey: string | null onSelect: (key: string) => void - onStockClick: (symbol: string, name?: string) => void + onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void + activeSymbol: string | null }) { return (
- - + +
) } @@ -596,13 +602,15 @@ function PulseList({ selectedKey, onSelect, onStockClick, + activeSymbol, }: { title: string items: IndustryStat[] mode: 'up' | 'down' selectedKey: string | null onSelect: (key: string) => void - onStockClick: (symbol: string, name?: string) => void + onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void + activeSymbol: string | null }) { const toneText = mode === 'up' ? 'text-bull' : 'text-bear' const toneBorder = mode === 'up' ? 'border-bull/20' : 'border-bear/20' @@ -621,7 +629,8 @@ function PulseList({
{items.map((item, idx) => { const active = selectedKey === item.key - const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3) + const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore) + const leaders = sortedStocks.slice(0, 3) const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0 const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0 const flatPct = Math.max(0, 100 - upPct - downPct) @@ -662,7 +671,7 @@ function PulseList({ {Array.from({ length: 3 }).map((_, i) => { const stock = leaders[i] return stock ? ( - { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}> + { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}> {stock.name || stock.symbol} @@ -743,10 +752,11 @@ function IndustryRail({ // ===== IndustryFocus(右侧聚焦面板) ===== -function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string) => void }) { +function IndustryFocus({ stat, onStockClick, activeSymbol }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) { if (!stat) return null const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS) const topLeaders = stocks.slice(0, 3) + const focusNav: NavItem[] = toNavItems(stocks) return (
@@ -775,7 +785,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt
- + onStockClick(sym, name, focusNav)} />
@@ -795,7 +805,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt {stocks.map((s, idx) => ( - onStockClick(s.symbol, s.name || undefined)}> + onStockClick(s.symbol, s.name || undefined, focusNav)}> {idx + 1}
{s.name || '—'}
@@ -826,7 +836,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st return
{label}
{value}
} -function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) { +function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) { if (!stocks.length) return
暂无龙头候选
return (
@@ -836,7 +846,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
{stocks.map((stock, idx) => ( -
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}> +
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
{idx === 0 ? '主龙头' : `辅龙 ${idx}`} {stock.leaderScore.toFixed(0)} diff --git a/frontend/src/pages/LimitUpLadder.tsx b/frontend/src/pages/LimitUpLadder.tsx index 41de236..035ce7b 100644 --- a/frontend/src/pages/LimitUpLadder.tsx +++ b/frontend/src/pages/LimitUpLadder.tsx @@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle } from 'lucide-react' import { DatePicker } from '@/components/DatePicker' import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' @@ -220,7 +220,7 @@ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedRe // ===== 单只股票卡片 ===== -const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick }: { +const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick, active }: { stock: LimitLadderStock extFields: ExtFieldConfig direction: Direction @@ -231,6 +231,8 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s hasDepth: boolean onClick: (symbol: string, name?: string) => void onDimensionClick: (kind: DimensionKind, value: string, sourceField?: string) => void + /** 正在 K 线弹窗预览中 → 高亮卡片 */ + active?: boolean }) { const [showMonitorMenu, setShowMonitorMenu] = useState(false) const [menuAnchor, setMenuAnchor] = useState(null) @@ -298,7 +300,7 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s event.preventDefault() onClick(stock.symbol, stock.name ?? undefined) }} - className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`} + className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''} ${active ? 'ring-1 ring-accent/60 ring-inset' : ''}`} style={style.cardStyle ? { ...style.cardStyle } : undefined} onMouseEnter={e => { if (!style.cardStyle || !style.hoverShadow) return @@ -926,7 +928,53 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel // ===== 梯队分组 ===== -function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: { +/** 与 TierGroup 卡片展示一致的过滤+排序 (监控优先 → 状态 → 封单量), 供切股导航列表复用 */ +function sortLadderStocks( + stocks: LimitLadderStock[], + opts: { + monitoredSymbols: Set + sealMode: 'vol' | 'amount' + selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null + extFields: ExtFieldConfig + }, +): LimitLadderStock[] { + return [...stocks] + .filter(s => { + if (!opts.selectedTag) return true + const item = opts.extFields[opts.selectedTag.fieldKey] + if (!item) return true + const tags = getExtTags(s, item) + return tags.includes(opts.selectedTag.tag) + }) + .sort((a, b) => { + // 开启监控的卡片排到分组最前 + const ma = opts.monitoredSymbols.has(a.symbol) ? 0 : 1 + const mb = opts.monitoredSymbols.has(b.symbol) ? 0 : 1 + if (ma !== mb) return ma - mb + const ord = (s: string) => { + if (s === 'limit_up' || s === 'limit_down' || !s) return 0 + if (s === 'broken' || s === 'recovery') return 1 + return 2 + } + const oa = ord(a.status ?? '') + const ob = ord(b.status ?? '') + if (oa !== ob) return oa - ob + // 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。 + // 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。 + if (oa === 0) { + const sealVal = (s: LimitLadderStock) => { + if (s.sealed_vol == null) return -1 + return opts.sealMode === 'amount' && s.close + ? s.sealed_vol * 100 * s.close + : s.sealed_vol + } + return sealVal(b) - sealVal(a) + } + return 0 + }) +} + +function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth, activeSymbol }: { tier: LimitLadderTier defaultOpen: boolean extFields: ExtFieldConfig @@ -942,6 +990,8 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, ladderRules: Map onMonitorChange: () => void hasDepth: boolean + /** 正在 K 线弹窗预览中 → 高亮对应卡片 */ + activeSymbol?: string | null }) { const isDarkTheme = useTheme() === 'dark' const [open, setOpen] = useState(defaultOpen) @@ -1085,40 +1135,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
)}
- {[...tier.stocks] - .filter(s => { - if (!selectedTag) return true - const item = extFields[selectedTag.fieldKey] - if (!item) return true - const tags = getExtTags(s, item) - return tags.includes(selectedTag.tag) - }) - .sort((a, b) => { - // 开启监控的卡片排到分组最前 - const ma = monitoredSymbols.has(a.symbol) ? 0 : 1 - const mb = monitoredSymbols.has(b.symbol) ? 0 : 1 - if (ma !== mb) return ma - mb - const ord = (s: string) => { - if (s === 'limit_up' || s === 'limit_down' || !s) return 0 - if (s === 'broken' || s === 'recovery') return 1 - return 2 - } - const oa = ord(a.status ?? '') - const ob = ord(b.status ?? '') - if (oa !== ob) return oa - ob - // 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。 - // 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。 - if (oa === 0) { - const sealVal = (s: typeof a) => { - if (s.sealed_vol == null) return -1 - return sealMode === 'amount' && s.close - ? s.sealed_vol * 100 * s.close - : s.sealed_vol - } - return sealVal(b) - sealVal(a) - } - return 0 - }).map(s => ( + {sortLadderStocks(tier.stocks, { monitoredSymbols, sealMode, selectedTag, extFields }).map(s => ( ))}
@@ -1490,6 +1508,7 @@ export function LimitUpLadder() { }, [showConcept]) const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + const [previewNavList, setPreviewNavList] = useState([]) const [selectedTag, setSelectedTag] = useState<{ fieldKey: 'concept' | 'industry'; tag: string } | null>(null) const [dimensionTarget, setDimensionTarget] = useState(null) const handleSelectTag = useCallback((sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => { @@ -1511,11 +1530,6 @@ export function LimitUpLadder() { storage.limitLadderExtFields.set(f) }, []) - const handleStockClick = useCallback((symbol: string, name?: string) => { - setPreviewSymbol(symbol) - setPreviewName(name ?? '') - }, []) - const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields]) const { data, isLoading, refetch, isFetching } = useQuery({ @@ -1532,9 +1546,27 @@ export function LimitUpLadder() { }, [asOf, data?.as_of]) const rawTiers = data?.tiers ?? [] - const tiers = filterTiers(rawTiers, filterKeys, extFields.bf) + // filterTiers 每次返回新数组, 不 memo 会破坏 React.memo(StockCard) 且全梯队二次排序 + const tiers = useMemo(() => filterTiers(rawTiers, filterKeys, extFields.bf), [rawTiers, filterKeys, extFields.bf]) const displayDate = data?.as_of ?? asOf + // 切股导航列表: 各梯队按展示同款排序展平 (监控优先 → 状态 → 封单量) + 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], + ) + + const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => { + setPreviewSymbol(symbol) + setPreviewName(name ?? '') + setPreviewNavList(navList ?? ladderNavItems) + }, [ladderNavItems]) + // sealed 降级判定 const sealedDegrade = useSealedDegrade(asOf, data?.as_of, data?.sealed_ready, data?.sealed_counts) @@ -1754,6 +1786,7 @@ export function LimitUpLadder() { ladderRules={ladderRules} onMonitorChange={refetchMonitorRules} hasDepth={sealedDegrade.hasDepth} + activeSymbol={previewSymbol} /> ))}
@@ -1761,9 +1794,9 @@ export function LimitUpLadder() { setDimensionTarget(null)} - onStockClick={(symbol, name) => { + onStockClick={(symbol, name, navList) => { setDimensionTarget(null) - handleStockClick(symbol, name) + handleStockClick(symbol, name, navList) }} /> @@ -1771,7 +1804,9 @@ export function LimitUpLadder() { setPreviewSymbol(null)} + onClose={() => { setPreviewSymbol(null); setPreviewNavList([]) }} + navList={previewNavList} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> {/* 字段配置弹窗 */} diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index a7e1a19..dd7beb9 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo } from 'react' +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { useNavigate, useSearchParams, Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' @@ -17,7 +17,7 @@ import { boardTag } from '@/components/stock-table/primitives' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge' import { RuleEditor } from '@/components/monitor/RuleEditor' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog' import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' @@ -339,6 +339,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs const resetTimer = useRef | null>(null) const [previewEv, setPreviewEv] = useState(null) const [memberPreview, setMemberPreview] = useState<{ symbol: string; name?: string } | null>(null) + const [previewNavList, setPreviewNavList] = useState([]) const [dimensionTarget, setDimensionTarget] = useState(null) const clearMut = useMutation({ @@ -367,6 +368,22 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs const events = (alertsQuery.data as any)?.alerts ?? [] + // 切股导航列表: 有 symbol 的触发记录 (按展示顺序) + const alertsNavItems = useMemo( + () => toNavItems(events.filter((ev: AlertEvent) => ev.symbol)), + [events], + ) + const handlePreviewEvent = useCallback((ev: AlertEvent) => { + setPreviewEv(ev) + setPreviewNavList(alertsNavItems) + }, [alertsNavItems]) + // 弹窗内切股: 来自成分弹窗则更新 memberPreview, 否则按 symbol 找到对应事件 (保住 triggerInfo) + const handleNavigate = useCallback((sym: string, name?: string) => { + if (memberPreview) { setMemberPreview({ symbol: sym, name }); return } + const ev = events.find((e: AlertEvent) => e.symbol === sym) + if (ev) setPreviewEv(ev) + }, [memberPreview, events]) + return (
{alertsQuery.isLoading ? ( @@ -418,7 +435,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs const board = boardTag(ev.symbol) return (
@@ -695,6 +715,14 @@ function RulesList({ rulesQuery, onEdit }: { }) const symbolNames = namesQuery.data?.names ?? {} + // 切股导航列表: 个股规则 (取第一个 symbol, 按展示顺序) + 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), + [rules, symbolNames], + ) + const del = useMutation({ mutationFn: api.monitorRuleDelete, onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }), @@ -930,6 +958,8 @@ function RulesList({ rulesQuery, onEdit }: { setPreviewSymbol(sym)} onClose={() => setPreviewSymbol(null)} />
diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index c7f09ee..bc3f27d 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 } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems } from '@/components/StockPreviewDialog' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { useStrategyPool } from '@/lib/useStrategyPool' import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard' @@ -405,6 +405,9 @@ 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), @@ -982,8 +985,9 @@ export function Screener() { strategyIdToName={strategyIdToName} symbolStrategyMap={symbolStrategyMap} activeStrategy={activeStrategy} + activeSymbol={previewSymbol} watchlistSet={watchlistSet} - onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }} + onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name ?? '') }} onAddToWatchlist={(symbol, groupId) => toggleWatchlist.mutate({ symbol, action: 'add', groupId })} onRemoveFromWatchlist={symbol => toggleWatchlist.mutate({ symbol, action: 'remove' })} watchlistPending={toggleWatchlist.isPending} @@ -1035,6 +1039,8 @@ export function Screener() { symbol={previewSymbol} name={previewName} onClose={closePreview} + navList={previewNavItems} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> void onDimensionClick: (target: DimensionMembersTarget) => void isMonitored?: boolean + /** 正在 K 线弹窗预览中 → 高亮卡片 */ + active?: boolean groups: WatchlistGroup[] onToggleMember: (symbol: string, groupId: string, member: boolean) => void groupChangePending: boolean @@ -510,7 +514,7 @@ const StockCard = React.memo(function StockCard({ return (
onPreview(r.symbol, name ?? '')} > {/* 左侧彩色指示条 */} @@ -1255,6 +1259,12 @@ export function Watchlist() { [filteredRows, sortRows, columns], ) + // 切股导航列表: 按列表当前展示顺序 (与 sortedRows 一致, 排序/筛选后行序随之变化) + const previewNavItems = useMemo( + () => toNavItems(sortedRows), + [sortedRows], + ) + const cardColumns = useCardColumnCount() const cardGridRef = useRef(null) const virtualizeCards = viewMode === 'card' && !groupCardsOpen && sortedRows.length > VIRTUAL_LIST_THRESHOLD @@ -1341,6 +1351,7 @@ export function Watchlist() { onToggleExpand={handleToggleExpand} onDimensionClick={setDimensionTarget} isMonitored={monitoredSymbols.has(r.symbol)} + active={previewSymbol === r.symbol} groups={groups} onToggleMember={handleToggleMember} groupChangePending={addGroupMember.isPending || removeGroupMember.isPending} @@ -1668,7 +1679,7 @@ export function Watchlist() { onSortToggle={handleSortToggle} extraSortableKeys={INTRADAY_SORTABLE_KEYS} rowKey={(r: any) => r.symbol} - rowClassName={() => 'border-t border-border hover:bg-elevated/50 transition-colors duration-150 ease-smooth'} + rowClassName={(r: any) => cn('border-t border-border transition-colors duration-150 ease-smooth hover:bg-elevated/50', r.symbol === previewSymbol && 'bg-accent/10 hover:bg-accent/15')} // 日k列表头:标签 + 显示/隐藏眼睛按钮 renderHeaderContent={(col) => { if (col.source.type === 'builtin' && col.source.key === 'candle') { @@ -1990,6 +2001,8 @@ export function Watchlist() { symbol={previewSymbol} name={previewName} onClose={closePreview} + navList={previewNavItems} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> Date: Thu, 27 Aug 2026 10:08:08 +0800 Subject: [PATCH 02/12] fix(dialog): dedup nav list so stock preview skips duplicate symbols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一标的在榜单中多次出现(多概念/行业 leader、监控重复触发)时, 去重后保留首次出现, 避免切股与 n/N 计数空跳。 Co-Authored-By: Claude --- frontend/src/components/StockPreviewDialog.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 42f5501..352a9bc 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -51,6 +51,18 @@ function wrapNavIndex(navIdx: number, delta: number, navTotal: number): number { return (navIdx + delta + navTotal) % navTotal } +/** 榜单里同一标的可能多次出现 (多概念/行业 leader、监控重复触发), 去重以免切股/计数空跳; 保留首次出现。 */ +function uniqueNavItems(xs: NavItem[]): NavItem[] { + const seen = new Set() + const out: NavItem[] = [] + for (const n of xs) { + if (seen.has(n.symbol)) continue + seen.add(n.symbol) + out.push(n) + } + return out +} + // ===== 板块标识(与 Screener 列表一致)===== // 预设快捷范围(只保留半年和1年) @@ -155,7 +167,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList }) // ===== 切股导航 ===== - const navList = useMemo(() => navListSource ?? [], [navListSource]) + const navList = useMemo(() => uniqueNavItems(navListSource ?? []), [navListSource]) // 当前 symbol 在 navList 中的位置 (不在列表则为 -1, 此时不显示计数/按钮) const navIdx = navList.findIndex(n => n.symbol === symbol) From ef66877529961e3ca25e5b213d597bcea2bac07e Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:14:11 +0800 Subject: [PATCH 03/12] 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) { From 0f7c1dfe397c3e44a8a318f98b8838755937e8d6 Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:15:26 +0800 Subject: [PATCH 04/12] feat(kline): show since-prev-close change and period count on hover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 悬停某根K线时, 图表自带信息栏额外显示「至今」(该K线昨收到最新收盘的涨跌幅) 与「周期 N」(到最新K线共多少根); 鼠标移出图表区仅隐藏该字段, 其余保持。 - updateAxisPointer 重写: 区分 悬停K线变化 与 竖虚线显隐, 分别控制信息栏重绘与副图 graphic - 信息栏行高 height → min-height, 容纳「至今」字段不被裁剪 Co-Authored-By: Claude --- .../src/components/EChartsCandlestick.tsx | 75 ++++++++++++------- 1 file changed, 49 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 63035fd..595abbc 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useCallback, useMemo } from 'react' import { chartTheme, getTheme, useTheme } from '@/lib/theme' +import { fmtPct } from '@/lib/format' import * as echarts from 'echarts' import type { ECharts, EChartsOption } from 'echarts' @@ -832,6 +833,8 @@ export function EChartsCandlestick({ const infoIdxRef = useRef(data.length - 1) const compactRef = useRef(false) const userZoomRef = useRef<{ start: number; end: number } | null>(null) + // 竖虚线(crosshair)是否可见: 控制信息栏「至今」字段的显隐。鼠标移出图表区即 false。 + const hoverActiveRef = useRef(false) // 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值 const activeIndicatorsRef = useRef(activeIndicators) @@ -911,7 +914,7 @@ export function EChartsCandlestick({ const floatShares = stockInfo?.float_shares const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null - let html = `
` + let html = `
` html += `${d.date}` html += `` html += `${d.open.toFixed(2)}` @@ -930,11 +933,25 @@ export function EChartsCandlestick({ html += `换手` html += `${turnoverRate.toFixed(2)}%` } + // 至今: 仅当竖虚线(crosshair)在图上且鼠标悬停某根 K 线时显示。 + // 最新价取最后一根K线收盘 (后端 _maybe_inject_live_candle 盘中注入实时价, 收盘后即最近收盘)。 + // 基准取该K线昨收(前一日收盘), 与同花顺及全市场涨幅口径一致; 数据第一根K线无昨收则跳过。 + if (hoverActiveRef.current && prev && Number.isFinite(prev.close) && prev.close > 0) { + const latestPrice = data[data.length - 1].close + if (Number.isFinite(latestPrice)) { + const sinceRatio = (latestPrice - prev.close) / prev.close + const sinceClr = sinceRatio >= 0 ? THEME.bull : THEME.bear + html += `至今` + html += `${fmtPct(sinceRatio)}` + // 周期数: 从该K线(含)到最新一根K线共多少根; 悬停最后一根时为 1 + html += `周期 ${data.length - idx}` + } + } html += `
` // 第二行: MA + BOLL if (showMA) { - html += `
` + html += `
` if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` @@ -954,6 +971,8 @@ export function EChartsCandlestick({ infoIdxRef.current = data.length - 1 compactRef.current = false userZoomRef.current = null + // 新数据无悬停上下文, 隐藏「至今」; 下次鼠标移动时由 updateAxisPointer 重新置位 + hoverActiveRef.current = false }, [data.length]) // ===== 初始化 chart (只在 chartHeight 变化时重建) ===== @@ -965,32 +984,36 @@ export function EChartsCandlestick({ chartRef.current = chart // 鼠标移动 → 只更新 ref + DOM,不触发 React re-render - // 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏 + // 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏; 鼠标移出时仅隐藏「至今」。 chart.on('updateAxisPointer', (event: any) => { const axesInfo = event.axesInfo - if (!axesInfo) return // 鼠标移出图表区域,保持当前显示 - for (const info of Object.values(axesInfo)) { - const val = (info as any)?.value - if (val == null) continue - const d = dataRef.current - const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val) - if (idx >= 0 && idx < d.length) { - if (infoIdxRef.current === idx) return - infoIdxRef.current = idx - - // 直接更新信息栏 DOM (通过 ref 读取最新的生成函数) - const infoEl = infoBarRef.current - if (infoEl) { - const html = getInfoBarHTMLRef.current() - if (html) infoEl.innerHTML = html // 只在有内容时更新 - } - - // 更新子图 graphic - triggerInfoBarUpdate() - return + const d = dataRef.current + // 竖虚线是否正落在某根有效 K 线上 (鼠标在图表数据区内) + let foundIdx = -1 + if (axesInfo) { + for (const info of Object.values(axesInfo)) { + const val = (info as any)?.value + if (val == null) continue + const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val) + if (idx >= 0 && idx < d.length) { foundIdx = idx; break } } } - // 没有找到有效数据 — 不做任何操作,保持上次显示 + const active = foundIdx >= 0 + const idxChanged = foundIdx >= 0 && infoIdxRef.current !== foundIdx + const visChanged = active !== hoverActiveRef.current + hoverActiveRef.current = active + if (idxChanged) infoIdxRef.current = foundIdx + // 竖虚线显隐或悬停 K 线变化 → 重绘一次信息栏 (控制「至今」字段显隐 + 当前 K 线数据) + if (visChanged || idxChanged) { + const infoEl = infoBarRef.current + if (infoEl) { + const html = getInfoBarHTMLRef.current() + if (html) infoEl.innerHTML = html // 只在有内容时更新 + } + } + if (foundIdx < 0) return + // 更新子图 graphic (仅悬停 K 线变化时; 纯显隐切换不影响副图) + if (idxChanged) triggerInfoBarUpdate() }) chart.on('click', (params: any) => { @@ -1159,7 +1182,7 @@ export function EChartsCandlestick({ if (!d) return '' const floatShares = stockInfo?.float_shares const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null - let html = `
` + let html = `
` html += `${d.date}` html += `` html += `${d.open.toFixed(2)}` @@ -1182,7 +1205,7 @@ export function EChartsCandlestick({ } html += `
` if (showMA) { - html += `
` + html += `
` if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` From 3258325daa15a95a96c7c0a9cd9d0e4b8f1f575a Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:16:38 +0800 Subject: [PATCH 05/12] feat(kline): add configurable external stock link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 设置页新增「个股详情外链」: 可填 URL 模板(占位符 {code}/{market}/{symbol}), 信息条第一行右侧显示外链图标(新标签打开)。留空关闭。 - 新建 lib/stock-external-link.ts: load/save/build + scheme 白名单(http/https) + symbol 形状守卫 - storage.ts 新增 stockExternalTemplate kv Co-Authored-By: Claude --- frontend/src/components/StockInfoBar.tsx | 18 ++++++++++-- frontend/src/lib/stock-external-link.ts | 36 ++++++++++++++++++++++++ frontend/src/lib/storage.ts | 3 ++ frontend/src/pages/settings/System.tsx | 28 +++++++++++++++++- 4 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/stock-external-link.ts diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx index 84737cf..7f01ebd 100644 --- a/frontend/src/components/StockInfoBar.tsx +++ b/frontend/src/components/StockInfoBar.tsx @@ -1,10 +1,11 @@ import { useState, type ReactNode } from 'react' -import { Settings2, RadioTower, Star } from 'lucide-react' +import { Settings2, RadioTower, Star, ExternalLink } from 'lucide-react' import type { KlineRow, FinancialMetricRecord } from '@/lib/api' import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format' import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields' +import { buildStockExternalUrl, loadStockExternalTemplate } from '@/lib/stock-external-link' const BULL = '#C74040' const BEAR = '#2D9B65' @@ -235,6 +236,8 @@ export function StockInfoBar({ ) } + const extUrl = buildStockExternalUrl(loadStockExternalTemplate(), symbol) + return (
{/* Row 1: code, name, price, change, change% */} @@ -250,8 +253,19 @@ export function StockInfoBar({ {isUp ? '+' : ''}{fmtPrice(chgPct)}% - {/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */} + {/* 右侧操作按钮:外链 + 加自选 + 加监控 + 信息条配置 */}
+ {extUrl && ( + + + + )} {inWatchlist && onRemoveFromWatchlist ? (
+
+
+ +

个股详情外链

+
+ +
+
+
详情页 URL 模板
+
{"支持 {code} {market} {symbol} · 留空关闭外链"}
+
+ { + setExtTpl(e.target.value) + saveStockExternalTemplate(e.target.value) + }} + placeholder="https://..." + spellCheck={false} + className="w-[26rem] max-w-[60%] h-8 px-2.5 rounded-btn border border-border bg-base text-xs font-mono text-foreground focus:border-accent/50 focus:outline-none" + /> +
+
+
From 7038c7d627e4da2c9d4e951c185c7ff9f6bac3de Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:28:16 +0800 Subject: [PATCH 06/12] =?UTF-8?q?refactor(kline):=20simplify=20after=20rev?= =?UTF-8?q?iew=20=E2=80=94=20single-source=20queries,=20sort,=20and=20nav?= =?UTF-8?q?=20lists?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查后清理, 无行为变更: - 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 --- .../src/components/StockIntradayChart.tsx | 4 +-- .../components/StockMultiDayIntradayChart.tsx | 4 +-- frontend/src/components/StockPanel.tsx | 11 +++---- .../src/components/StockPreviewDialog.tsx | 2 ++ frontend/src/lib/kline.ts | 13 ++++++++ frontend/src/pages/ConceptAnalysis.tsx | 11 +++++-- frontend/src/pages/Dashboard.tsx | 9 +++--- frontend/src/pages/IndustryAnalysis.tsx | 11 +++++-- frontend/src/pages/LimitUpLadder.tsx | 32 +++++++++++-------- frontend/src/pages/Monitor.tsx | 2 +- frontend/src/pages/Screener.tsx | 16 ++++++---- frontend/src/pages/Watchlist.tsx | 7 ++-- 12 files changed, 78 insertions(+), 44 deletions(-) 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() From 41a97888b6c6869300964de7770d1fec1eed030b Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:32:05 +0800 Subject: [PATCH 07/12] fix(kline): widen dialog, balance chart split, keep intraday view on switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 弹窗 max-w 1100→1200, 日K/分时并排改 1.4:1 (日K为主视图), 图表信息栏(悬停含「至今/周期」)有足够宽度单行容纳, 不再换行 - 修复: 分时 tab 下弹窗内切股会跳回日K — 视图重置仅在弹窗首次打开(symbol 从 null 变非空)时发生, 切股保留当前视图 Co-Authored-By: Claude --- frontend/src/components/StockPanel.tsx | 3 ++- frontend/src/components/StockPreviewDialog.tsx | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 355df1e..60b006f 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -190,10 +190,11 @@ export function StockPanel({ {infoBarOnly ? null : (
+ {/* 日K为主视图占更宽 (与分时约 1.4:1): 图表自带信息栏(悬停含「至今/周期」)需要更宽才能单行容纳 */} document.removeEventListener('keydown', handler) }, [symbol, go, showMonitorEditor, priceAlertDraft]) + // 弹窗内切股时保留当前视图 (分时 tab 下切股不应跳回日K); + // 仅当弹窗首次打开 (symbol 从 null 变非空) 时重置为日K。 + const prevSymbolRef = useRef(symbol) useEffect(() => { - if (symbol) setView('daily') + if (prevSymbolRef.current == null && symbol != null) setView('daily') + prevSymbolRef.current = symbol setPriceAlertDraft(null) }, [symbol]) @@ -307,7 +311,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }} className={cn( 'relative rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col transition-all duration-200 ease-smooth', - maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1100px] max-h-[95vh]', + maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1200px] max-h-[95vh]', )} > {/* 顶栏 */} From f3b916c049e6623b39c5ab7dd5d4cbd2864fb8e0 Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:34:29 +0800 Subject: [PATCH 08/12] feat(kline): prefetch multi-day intraday so switching keeps intraday tab instant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 之前邻股只预取了日K/财务/单日分时(日K视图旁的分时图), 分时 tab 用的 多日分时(klineMinuteRange)没预取 → 分时 tab 切股仍要重新加载。 - lib/kline.ts: 新增 klineMinuteRangeQueryOptions, StockMultiDayIntradayChart 与预取共用 - StockPanel: 新增 intradayDays prop, 邻股预取补充 klineMinuteRange(days) + 最新分时 - StockPreviewDialog: 把当前 intradayDays 传给两个分支的 StockPanel, 保证 queryKey 命中 Co-Authored-By: Claude --- frontend/src/components/StockMultiDayIntradayChart.tsx | 6 ++---- frontend/src/components/StockPanel.tsx | 10 ++++++++-- frontend/src/components/StockPreviewDialog.tsx | 2 ++ frontend/src/lib/kline.ts | 8 ++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx index 09281a3..1c16380 100644 --- a/frontend/src/components/StockMultiDayIntradayChart.tsx +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -2,8 +2,7 @@ import { useEffect, useMemo, useRef } from 'react' 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 { klineMinuteQueryOptions, klineMinuteRangeQueryOptions } from '@/lib/kline' import { toast } from '@/components/Toast' import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday' @@ -30,8 +29,7 @@ export function StockMultiDayIntradayChart({ }: Props) { const queryClient = useQueryClient() const history = useQuery({ - queryKey: QK.klineMinuteRange(symbol, days), - queryFn: () => api.klineMinuteRange(symbol, days), + ...klineMinuteRangeQueryOptions(symbol, days), enabled: !!symbol, placeholderData: (previous, previousQuery) => previousQuery?.queryKey[1] === symbol ? previous : undefined, diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 60b006f..270df47 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -2,7 +2,7 @@ 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 { klineDailyQueryOptions, klineMinuteQueryOptions } from '@/lib/kline' +import { klineDailyQueryOptions, klineMinuteQueryOptions, klineMinuteRangeQueryOptions } from '@/lib/kline' import { StockInfoBar } from '@/components/StockInfoBar' import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDailyKChart' import { StockIntradayChart } from '@/components/StockIntradayChart' @@ -44,6 +44,8 @@ interface Props { infoBarOnly?: boolean /** 邻近预取目标 (切股导航的左右邻股): 提前拉取其日K/财务/分时缓存, 切换瞬间免 loading */ prefetchSymbols?: string[] + /** 多日分时周期 (分时 tab 使用): 预取邻股 klineMinuteRange 时用同一 days, 保证 queryKey 命中 */ + intradayDays?: number } export { getDefaultRange } @@ -69,6 +71,7 @@ export function StockPanel({ refetchIntervalMs, infoBarOnly = false, prefetchSymbols, + intradayDays = 10, }: Props) { const [linkedPrice, setLinkedPrice] = useState(null) const [selectedDate, setSelectedDate] = useState(null) @@ -127,6 +130,9 @@ export function StockPanel({ if (hasFinanceField && hasFinancialCap) { qc.prefetchQuery(financialMetricsQueryOptions(s)) } + // 分时 tab 的多日分时 + 最新分时: 切股后分时图也免 loading (与日K并行预取) + qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 }) + qc.prefetchQuery({ ...klineMinuteQueryOptions(s), staleTime: 30_000 }) // 日K用 fetchQuery (返回数据) 以便级联预取分时; 邻股预取失败静默, 不影响切股。 void qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 }) .then((res) => { @@ -141,7 +147,7 @@ export function StockPanel({ }) .catch(() => {}) } - }, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, qc]) + }, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, intradayDays, qc]) // symbol 变化时重置分时相关状态,避免切股后残留旧日期。 // 日K信息直接读 query data (切股到已预取邻股首帧即有), 无需清空或门控。 diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index d2877c9..9adf3e4 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -606,6 +606,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList onPriceDoubleClick={openPriceAlert} refetchIntervalMs={intradayRefetchMs} prefetchSymbols={prefetchSymbols} + intradayDays={intradayDays} /> ) : ( <> @@ -614,6 +615,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList dateRange={dateRange} infoBarOnly prefetchSymbols={prefetchSymbols} + intradayDays={intradayDays} /> api.klineMinute(symbol, date ?? undefined, live), } } + +/** 多日分时查询配置 — 分时 tab 的 StockMultiDayIntradayChart 与 邻近预取 共用。 */ +export function klineMinuteRangeQueryOptions(symbol: string, days: number) { + return { + queryKey: QK.klineMinuteRange(symbol, days), + queryFn: () => api.klineMinuteRange(symbol, days), + } +} From 4d36a5ff208e457932ee6baa67b20e59c70e9f90 Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:36:43 +0800 Subject: [PATCH 09/12] fix(kline): blur focused control after arrow-key switching to drop stray focus ring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点过分时tab/外链等控件后方向键切股, 浏览器会给该已聚焦控件显示 focus-visible 默认蓝色 outline(应用未自定义 focus 样式, 即浏览器默认环)。 切股成功后 blur 掉当前聚焦元素, 一次覆盖弹窗内所有控件。 Co-Authored-By: Claude --- frontend/src/components/StockPreviewDialog.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index 9adf3e4..8c22aab 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -224,7 +224,12 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList const t = e.target as HTMLElement | null if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return if (showMonitorEditor) return - if (go(e.key === 'ArrowRight' ? 1 : -1)) e.preventDefault() + if (go(e.key === 'ArrowRight' ? 1 : -1)) { + e.preventDefault() + // 切股后清掉控件残留的键盘焦点: 点过分时tab/外链等控件后方向键切股, + // 浏览器会给该控件显示 focus-visible 默认蓝色 outline, 切换后 blur 掉避免残留 + ;(document.activeElement as HTMLElement | null)?.blur() + } } } document.addEventListener('keydown', handler) From 341b80e7abbf712816ca52549f52cc6b2dce4ad2 Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:42:39 +0800 Subject: [PATCH 10/12] =?UTF-8?q?refactor(kline):=20simplify=20round=202?= =?UTF-8?q?=20=E2=80=94=20scope=20flex=20ratio,=20single-source=20guard=20?= =?UTF-8?q?and=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审查后清理, 无行为变更 (除下述作用域修正): - StockPanel: 日K/分时并排的占宽从硬编码 flex-[1.4] 改为 dailyKlineFlex prop (默认 flex-1) —— 避免静默改变 TradeKlineModal 等其他消费方的并排布局; 仅 StockPreviewDialog 显式传入 flex-[1.4] - lib/kline.ts: klineMinuteRangeQueryOptions 内嵌「仅同 symbol」placeholderData 守卫, 与 klineDailyQueryOptions 同源, 删除 StockMultiDayIntradayChart 的内联副本 - DEFAULT_INTRADAY_DAYS 单源化: StockPanel 默认值与弹窗 loadIntradayDays 回退共用 - StockPreviewDialog: prevSymbolRef 初始值改 null (首次挂载 view 本就是 daily); blur 复用 keydown 的 e.target(t) 替代重复 document.activeElement 查询 Co-Authored-By: Claude --- .../components/StockMultiDayIntradayChart.tsx | 2 -- frontend/src/components/StockPanel.tsx | 10 ++++++---- frontend/src/components/StockPreviewDialog.tsx | 16 ++++++++++------ frontend/src/lib/kline.ts | 11 ++++++++++- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/StockMultiDayIntradayChart.tsx b/frontend/src/components/StockMultiDayIntradayChart.tsx index 1c16380..6f71c85 100644 --- a/frontend/src/components/StockMultiDayIntradayChart.tsx +++ b/frontend/src/components/StockMultiDayIntradayChart.tsx @@ -31,8 +31,6 @@ export function StockMultiDayIntradayChart({ const history = useQuery({ ...klineMinuteRangeQueryOptions(symbol, days), enabled: !!symbol, - placeholderData: (previous, previousQuery) => - previousQuery?.queryKey[1] === symbol ? previous : undefined, }) const latest = useQuery({ // live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢 diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 270df47..4e2e586 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -2,7 +2,7 @@ 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 { klineDailyQueryOptions, klineMinuteQueryOptions, klineMinuteRangeQueryOptions } from '@/lib/kline' +import { klineDailyQueryOptions, klineMinuteQueryOptions, klineMinuteRangeQueryOptions, DEFAULT_INTRADAY_DAYS } from '@/lib/kline' import { StockInfoBar } from '@/components/StockInfoBar' import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDailyKChart' import { StockIntradayChart } from '@/components/StockIntradayChart' @@ -46,6 +46,8 @@ interface Props { prefetchSymbols?: string[] /** 多日分时周期 (分时 tab 使用): 预取邻股 klineMinuteRange 时用同一 days, 保证 queryKey 命中 */ intradayDays?: number + /** 日K/分时并排时日K图占宽 (默认 1:1; 弹窗内图表信息栏较宽需更多空间时传 flex-[1.4] 之类) */ + dailyKlineFlex?: string } export { getDefaultRange } @@ -71,7 +73,8 @@ export function StockPanel({ refetchIntervalMs, infoBarOnly = false, prefetchSymbols, - intradayDays = 10, + intradayDays = DEFAULT_INTRADAY_DAYS, + dailyKlineFlex = 'flex-1', }: Props) { const [linkedPrice, setLinkedPrice] = useState(null) const [selectedDate, setSelectedDate] = useState(null) @@ -196,11 +199,10 @@ export function StockPanel({ {infoBarOnly ? null : (
- {/* 日K为主视图占更宽 (与分时约 1.4:1): 图表自带信息栏(悬停含「至今/周期」)需要更宽才能单行容纳 */} (symbol) + const prevSymbolRef = useRef(null) useEffect(() => { if (prevSymbolRef.current == null && symbol != null) setView('daily') prevSymbolRef.current = symbol @@ -612,6 +615,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList refetchIntervalMs={intradayRefetchMs} prefetchSymbols={prefetchSymbols} intradayDays={intradayDays} + dailyKlineFlex="flex-[1.4]" /> ) : ( <> diff --git a/frontend/src/lib/kline.ts b/frontend/src/lib/kline.ts index 8abdbc7..fdd4f40 100644 --- a/frontend/src/lib/kline.ts +++ b/frontend/src/lib/kline.ts @@ -10,6 +10,9 @@ import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' +/** 分时 tab 多日分时默认周期 (StockPanel 预取与弹窗存储回退共用, 避免魔数两处漂移) */ +export const DEFAULT_INTRADAY_DAYS = 10 + export function klineDailyQueryOptions( symbol: string, dateRange: { start: string; end: string }, @@ -39,10 +42,16 @@ export function klineMinuteQueryOptions(symbol: string, date?: string, live?: bo } } -/** 多日分时查询配置 — 分时 tab 的 StockMultiDayIntradayChart 与 邻近预取 共用。 */ +/** 多日分时查询配置 — 分时 tab 的 StockMultiDayIntradayChart 与 邻近预取 共用。 + * 内嵌「仅同 symbol 占位」守卫 (key 结构 ['kline-minute-range', symbol, days], index 1 为 symbol), + * 与 klineDailyQueryOptions 同源, 调用点不再各自手写。 */ export function klineMinuteRangeQueryOptions(symbol: string, days: number) { return { queryKey: QK.klineMinuteRange(symbol, days), queryFn: () => api.klineMinuteRange(symbol, days), + placeholderData: (prev: any, prevQuery: any) => { + const prevKey = prevQuery?.queryKey as readonly unknown[] | undefined + return prevKey?.[1] === symbol ? prev : undefined + }, } } From 6964e1b5c9c9ba067b6b8f46e1c072b6eb49e84b Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:53:33 +0800 Subject: [PATCH 11/12] fix(kline): two regressions found in PR review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EChartsCandlestick: 悬停重置 effect 依赖补上 symbol(_symbol) —— 预取切股到同长度邻股时 data.length 不变, 原 [data.length] 不触发, hoverActiveRef 残留导致切股后「至今/周期」无悬停仍显示 (自愈于下次 mousemove) - Watchlist: DimensionMembersDialog 的 onStockClick 补第三参 navList —— 从成分弹窗打开个股时用成分列表作切股导航 (成员可能不在自选列表, 原实现退回自选列表, 成员不在自选则导航按钮不显示) Co-Authored-By: Claude --- frontend/src/components/EChartsCandlestick.tsx | 7 +++++-- frontend/src/pages/Watchlist.tsx | 11 ++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 595abbc..3bebb57 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -966,14 +966,17 @@ export function EChartsCandlestick({ }, [data, stockInfo, showMA, activeIndicators]) getInfoBarHTMLRef.current = getInfoBarHTML - // data 变化时重置 infoIdx + // data/symbol 变化时重置 infoIdx: + // symbol(_symbol) 进依赖是必要的——预取切股到同长度邻股时 data.length 不变, + // 但悬停上下文来自上一只股票, 必须清掉 hoverActiveRef 以免「至今/周期」残留显示。 + // (同一股的实时刷新 symbol 不变, 不触发, 悬停位置与「至今」保持实时) useEffect(() => { infoIdxRef.current = data.length - 1 compactRef.current = false userZoomRef.current = null // 新数据无悬停上下文, 隐藏「至今」; 下次鼠标移动时由 updateAxisPointer 重新置位 hoverActiveRef.current = false - }, [data.length]) + }, [_symbol, data.length]) // ===== 初始化 chart (只在 chartHeight 变化时重建) ===== useEffect(() => { diff --git a/frontend/src/pages/Watchlist.tsx b/frontend/src/pages/Watchlist.tsx index 7211032..b4e93d1 100644 --- a/frontend/src/pages/Watchlist.tsx +++ b/frontend/src/pages/Watchlist.tsx @@ -13,7 +13,7 @@ import { cn } from '@/lib/cn' import { computeGroupPcts, loadGroupStatsConfig, type GroupStatsConfigPatch } from '@/lib/watchlistGroupStats' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' -import { StockPreviewDialog, toNavItems } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { DimensionMembersDialog, dimensionKindForSourceField, @@ -780,11 +780,14 @@ export function Watchlist() { }, []) const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + // 切股导航: 默认用 previewNavItems(自选列表); 从成分弹窗打开时用成分列表覆盖 + const [previewNavList, setPreviewNavList] = useState([]) const [dimensionTarget, setDimensionTarget] = useState(null) const [expandedCells, setExpandedCells] = useState>(new Set()) const closePreview = useCallback(() => { setPreviewSymbol(null) setPreviewName('') + setPreviewNavList([]) }, []) const handleToggleExpand = useCallback((cellKey: string) => { @@ -2002,17 +2005,19 @@ export function Watchlist() { symbol={previewSymbol} name={previewName} onClose={closePreview} - navList={previewNavItems} + navList={previewNavList.length > 0 ? previewNavList : previewNavItems} onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> setDimensionTarget(null)} - onStockClick={(symbol, name) => { + onStockClick={(symbol, name, navList) => { setDimensionTarget(null) setPreviewSymbol(symbol) setPreviewName(name ?? '') + // 成分列表作为切股导航 (成员可能不在自选列表, 不能退回 previewNavItems) + setPreviewNavList(navList ?? previewNavItems) }} /> From 0a11bb870ec4d8d904daa01d2278491402716d35 Mon Sep 17 00:00:00 2001 From: richard Date: Wed, 2 Sep 2026 13:18:18 +0800 Subject: [PATCH 12/12] fix(kline): align intraday prefetch with rendering (live & effective days) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebase 到最新 main 后,按 review 提的两点把邻股预取与真实渲染路径对齐: - 邻股预取当日最新分时补 live=true: 与 StockMultiDayIntradayChart 的 latest 同读 实时源,盘中切股不回落分钟增量落盘的旧本地分区 (历史日期后端自行忽略 live) - StockPanel 两处预取 days 改用 effectiveIntradayDays, 与多日图实际渲染周期一致, range 预取 queryKey 命中, 切换分时 tab 即时有数据 - 附带清理 rebase 冲突解析产生的残留右括号与冗余注释 Co-Authored-By: Claude --- frontend/src/components/StockDailyKChart.tsx | 1 - frontend/src/components/StockPanel.tsx | 5 +++-- frontend/src/components/StockPreviewDialog.tsx | 5 ++--- frontend/src/lib/kline.ts | 4 ++-- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index f456131..142a1a5 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -131,7 +131,6 @@ export function StockDailyKChart({ const dateRange = externalDateRange ?? getDefaultRange() // 查询配置统一来自 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]) diff --git a/frontend/src/components/StockPanel.tsx b/frontend/src/components/StockPanel.tsx index 4e2e586..160b1e1 100644 --- a/frontend/src/components/StockPanel.tsx +++ b/frontend/src/components/StockPanel.tsx @@ -135,7 +135,8 @@ export function StockPanel({ } // 分时 tab 的多日分时 + 最新分时: 切股后分时图也免 loading (与日K并行预取) qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 }) - qc.prefetchQuery({ ...klineMinuteQueryOptions(s), 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) => { @@ -144,7 +145,7 @@ export function StockPanel({ const lastDate = res?.rows?.at(-1)?.date if (lastDate) { const d = String(lastDate).slice(0, 10) - // live=true: 若 d 为当日, 预取即命中实时源, 与实际渲染的 queryKey 同源 (历史日期后端忽略 live) + // 同上 live=true: 该日若为当日即命中实时源 (历史日期后端忽略 live) qc.prefetchQuery({ ...klineMinuteQueryOptions(s, d, true), staleTime: 30_000 }) } }) diff --git a/frontend/src/components/StockPreviewDialog.tsx b/frontend/src/components/StockPreviewDialog.tsx index bb48255..527d8ed 100644 --- a/frontend/src/components/StockPreviewDialog.tsx +++ b/frontend/src/components/StockPreviewDialog.tsx @@ -86,7 +86,6 @@ function loadIntradayDays(): number { ? saved : DEFAULT_INTRADAY_DAYS } -} function boardTag(symbol: string): { label: string; color: string } | null { if (/^(300|301)/.test(symbol)) return { label: '创', color: 'text-[#f97316] bg-[#f97316]/12 border-[#f97316]/25' } @@ -614,7 +613,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList onPriceDoubleClick={openPriceAlert} refetchIntervalMs={intradayRefetchMs} prefetchSymbols={prefetchSymbols} - intradayDays={intradayDays} + intradayDays={effectiveIntradayDays} dailyKlineFlex="flex-[1.4]" /> ) : ( @@ -624,7 +623,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo, navList dateRange={dateRange} infoBarOnly prefetchSymbols={prefetchSymbols} - intradayDays={intradayDays} + intradayDays={effectiveIntradayDays} />