From 571e828529d725893028188227dfc8c2765107f9 Mon Sep 17 00:00:00 2001 From: richard Date: Thu, 27 Aug 2026 10:07:42 +0800 Subject: [PATCH] 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 ?? '') }} />