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
}
-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) */}
|