feat(kline): support in-dialog stock switching with arrow keys

K线弹窗支持在来源榜单内切股: 顶栏 ◀ n/N ▶ 按钮 + ←/→ 方向键(输入框/编辑器内让位),
首↔尾循环弱提示, 并给来源页当前预览行加高亮。

- StockPreviewDialog: 新增 navList/onNavigate props, 导出 NavItem/toNavItems;
  合并 ESC 与方向键监听; wrapMsg 弱提示浮层
- 9 个来源页面(自选/监控/选股/概念/行业/看板/连板/成分)构建 navList 并传 activeSymbol

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
richard
2026-09-02 13:11:54 +08:00
co-authored by Claude
parent bd84d4c0ae
commit 571e828529
10 changed files with 379 additions and 134 deletions
@@ -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<P
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
onClick={() => 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)` }}
+108 -6
View File
@@ -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<T extends { symbol: string; name?: string | null }>(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<PreviewView>('daily')
const [intradayDays, setIntradayDays] = useState<number | null>(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<string | null>(null)
const wrapTimer = useRef<number | null>(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
})()}
<span className="shrink-0 font-mono text-sm font-medium text-foreground">{symbol}</span>
{name && <span className="truncate text-xs text-muted">{name}</span>}
{/* 切股导航: 上一只 / n·N / 下一只 */}
{navEnabled && (
<>
<span className="mx-0.5 shrink-0 text-muted/20">|</span>
<button
onClick={() => go(-1)}
title="上一只 (←)"
aria-label="上一只"
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors cursor-pointer"
>
<ChevronLeft className="h-3.5 w-3.5" />
</button>
<span className="shrink-0 font-mono text-[11px] text-secondary tabular-nums whitespace-nowrap">
{navIdx + 1} / {navTotal}
</span>
<button
onClick={() => go(1)}
title="下一只 (→)"
aria-label="下一只"
className="p-1 rounded-btn text-secondary hover:text-foreground hover:bg-elevated transition-colors cursor-pointer"
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
@@ -546,6 +633,21 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
</motion.div>
)}
</AnimatePresence>
{/* 首↔尾循环弱提示 */}
<AnimatePresence>
{wrapMsg && (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 8 }}
transition={{ duration: 0.2 }}
className="pointer-events-none absolute bottom-4 left-1/2 z-30 -translate-x-1/2 rounded-full border border-border bg-surface/95 px-3 py-1.5 text-[11px] text-secondary shadow-lg backdrop-blur"
>
{wrapMsg}
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
)}
@@ -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<string, string[]>
activeStrategy: string | null
watchlistSet: Set<string>
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<Set<string>>(new Set())
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
@@ -244,7 +248,7 @@ export function ScreenerTable({
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onPreview(r.symbol, r.name ?? '')}
onClick={() => onPreview(r.symbol, r.name ?? '', toNavItems(rows))}
className={`flex items-center gap-2 text-left ${isExpired ? 'cursor-default' : ''}`}
>
{board ? (
@@ -393,10 +397,12 @@ export function ScreenerTable({
onSortToggle={onSortToggle}
minWidth={Math.max(900, columns.filter(c => c.visible).length * 110)}
rowKey={(r: any) => `${r.symbol}${r._expired ? '-expired' : ''}`}
rowClassName={(r: any) => r._expired
? 'border-border/50 opacity-40'
: 'border-border hover:bg-elevated/50'
}
rowClassName={(r: any) => cn(
r._expired
? 'border-border/50 opacity-40'
: 'border-border hover:bg-elevated/50',
r.symbol === activeSymbol && 'bg-accent/10',
)}
// 日k / 分时列表头:标签 + 显示/隐藏的眼睛按钮(与自选页一致)
renderHeaderContent={(col) => {
if (col.source.type !== 'builtin') return undefined
@@ -487,9 +493,9 @@ export function ScreenerTable({
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
onStockClick={(symbol, name, navList) => {
setDimensionTarget(null)
onPreview(symbol, name ?? '')
onPreview(symbol, name ?? '', navList)
}}
/>
</>
+25 -15
View File
@@ -15,7 +15,7 @@ import {
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -241,6 +241,7 @@ export function ConceptAnalysis() {
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const [showRps, setShowRps] = useState(false)
const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList })
@@ -393,7 +394,8 @@ export function ConceptAnalysis() {
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 ?? []) }}
/>
{stats.length > 0 ? (
@@ -407,7 +409,7 @@ export function ConceptAnalysis() {
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<ConceptFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
<ConceptFocus stat={selected} activeSymbol={previewSymbol} onStockClick={(sym, name, navList) => { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
@@ -433,7 +435,9 @@ export function ConceptAnalysis() {
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { 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 (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
</div>
)
}
@@ -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({
<div className="space-y-1">
{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 ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { 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')}>
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { 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')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
@@ -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 (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
@@ -706,7 +716,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
<ScoreExplain stock={topLeaders[0]} />
</div>
@@ -726,7 +736,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
@@ -757,7 +767,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
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 <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
@@ -767,7 +777,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => 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')}>
<div key={stock.symbol} onClick={() => 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')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
+61 -29
View File
@@ -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<string, string> = {
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'}`}
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
<div className="flex items-center gap-1.5">
<button
onClick={() => isSector ? navigate('/monitor') : ev.symbol && onStockClick(ev)}
onClick={() => isSector ? navigate('/monitor') : ev.symbol && onStockClick(ev, alertNav)}
title={isSector ? '在监控中心查看板块告警' : ev.symbol ? `查看 ${ev.symbol} 日K` : undefined}
className={`inline-flex items-center gap-1 min-w-0 shrink-0 rounded hover:bg-elevated/60 transition-colors -mx-0.5 px-0.5 ${isSector || ev.symbol ? 'cursor-pointer' : 'cursor-default'}`}
>
@@ -409,9 +414,10 @@ function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string;
)
}
function StockList({ title, rows, mode, onStockClick }: {
function StockList({ title, rows, mode, onStockClick, activeSymbol }: {
title: string; rows: MarketSnapshotRow[]; mode: 'gain' | 'loss' | 'amount' | 'active';
onStockClick?: (symbol: string, name?: string) => void;
activeSymbol?: string;
}) {
return (
<div className="rounded-card border border-border bg-surface/80 p-1.5 shadow-[0_1px_2px_hsl(var(--border)/0.4)] backdrop-blur-sm transition-shadow hover:shadow-[0_2px_8px_hsl(var(--border)/0.5)]">
@@ -423,7 +429,7 @@ function StockList({ title, rows, mode, onStockClick }: {
{rows.slice(0, 8).map((r, idx) => (
<div
key={`${r.symbol}-${idx}`}
className="grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded-md bg-elevated/40 px-1.5 py-1 cursor-pointer hover:bg-elevated hover:brightness-110 transition-colors border border-transparent hover:border-border/60"
className={`grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded-md px-1.5 py-1 cursor-pointer transition-colors border ${r.symbol === activeSymbol ? 'bg-accent/10 border-accent/30' : 'bg-elevated/40 border-transparent hover:bg-elevated hover:brightness-110 hover:border-border/60'}`}
onClick={() => onStockClick?.(r.symbol, r.name ?? undefined)}
>
<span className="text-center font-mono text-[10px] text-muted">{idx + 1}</span>
@@ -467,10 +473,11 @@ function StockList({ title, rows, mode, onStockClick }: {
)
}
function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
function RankColumn({ title, rows, tone, onStockClick, onDimensionClick, activeSymbol }: {
title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear';
onStockClick?: (symbol: string, name?: string) => void
onDimensionClick?: (target: DimensionMembersTarget) => void
onStockClick?: (symbol: string, name?: string) => void;
onDimensionClick?: (target: DimensionMembersTarget) => void;
activeSymbol?: string;
}) {
return (
<div className="min-w-0 space-y-1">
@@ -478,6 +485,7 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
{rows.slice(0, 5).map((r, idx) => {
const kind = r.source_field ? dimensionKindForSourceField(r.source_field) : null
const clickable = !!(r.source_field && kind && onDimensionClick)
const isActive = r.leader?.symbol != null && r.leader.symbol === activeSymbol
return (
<div
key={`${title}-${r.name}-${idx}`}
@@ -487,9 +495,9 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
sourceField: r.source_field!,
})}
title={clickable ? `查看「${r.name}」成分股` : undefined}
className={`grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md bg-elevated/40 px-1.5 py-1 border border-transparent transition-colors ${
clickable ? 'cursor-pointer hover:border-accent/40 hover:bg-elevated/70' : 'hover:border-border/60'
}`}
className={`grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md px-1.5 py-1 border transition-colors ${
isActive ? 'border-accent/30 bg-accent/10' : 'border-transparent bg-elevated/40'
} ${clickable ? 'cursor-pointer hover:border-accent/40 hover:bg-elevated/70' : 'hover:border-border/60'}`}
>
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
<div className="min-w-0">
@@ -533,10 +541,11 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
)
}
function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }: {
function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick, activeSymbol }: {
title: string; rank?: OverviewMarket['concept_rank']; configUrl: string;
onStockClick?: (symbol: string, name?: string) => void
onDimensionClick?: (target: DimensionMembersTarget) => void
onStockClick?: (symbol: string, name?: string) => void;
onDimensionClick?: (target: DimensionMembersTarget) => void;
activeSymbol?: string;
}) {
const hasData = (rank?.leading?.length ?? 0) > 0 || (rank?.lagging?.length ?? 0) > 0
return (
@@ -544,8 +553,8 @@ function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }:
<SectionTitle icon={Flame} title={title} hint="领涨/领跌 · 点击板块看成分股" />
{hasData ? (
<div className="grid grid-cols-2 gap-2">
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} onDimensionClick={onDimensionClick} activeSymbol={activeSymbol} />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} onDimensionClick={onDimensionClick} activeSymbol={activeSymbol} />
</div>
) : (
<div className="py-4 text-center">
@@ -562,11 +571,29 @@ function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }:
)
}
// 切股导航列表构建 (与列表展示行一致: StockList 只显示前 8)
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 }))
}
export function Dashboard() {
const qc = useQueryClient()
const [selectedDate, setSelectedDate] = useState<string | undefined>()
const [manualFetching, setManualFetching] = useState(false)
const [previewStock, setPreviewStock] = useState<{symbol: string; name?: string; alert?: AlertEvent} | null>(null)
const [previewStock, setPreviewStock] = useState<{
symbol: string
name?: string
alert?: AlertEvent
/** 打开来源榜: 仅高亮来源榜的行 */
source?: 'gain' | 'loss' | 'amount' | 'active' | 'concept' | 'industry' | 'alert'
/** 切股导航列表 (来自来源榜) */
navList?: NavItem[]
} | null>(null)
// 板块成分股弹窗 (概念/行业热度卡片行点击)
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
// 首次使用(无数据 + 未完成引导)自动弹窗: 同一会话只弹一次
@@ -855,19 +882,19 @@ export function Dashboard() {
</div>
<div className="grid grid-cols-1 gap-1.5 md:grid-cols-2">
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis"
onStockClick={(symbol, name) => setPreviewStock({symbol, name})}
<HotRankCard title="概念热度" rank={data.concept_rank} configUrl="/concept-analysis" activeSymbol={previewStock?.source === 'concept' ? previewStock.symbol : undefined}
onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'concept', navList: rankNav(data.concept_rank) })}
onDimensionClick={setDimensionTarget} />
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis"
onStockClick={(symbol, name) => setPreviewStock({symbol, name})}
<HotRankCard title="行业热度" rank={data.industry_rank} configUrl="/industry-analysis" activeSymbol={previewStock?.source === 'industry' ? previewStock.symbol : undefined}
onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'industry', navList: rankNav(data.industry_rank) })}
onDimensionClick={setDimensionTarget} />
</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2 xl:grid-cols-4">
<StockList title="涨幅榜" rows={data.top_gainers} mode="gain" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
<StockList title="跌幅榜" rows={data.top_losers} mode="loss" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
<StockList title="成交额榜" rows={data.turnover_leaders} mode="amount" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
<StockList title="活跃换手" rows={data.active_leaders} mode="active" onStockClick={(symbol, name) => setPreviewStock({symbol, name})} />
<StockList title="涨幅榜" rows={data.top_gainers} mode="gain" activeSymbol={previewStock?.source === 'gain' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'gain', navList: stockListNav(data.top_gainers) })} />
<StockList title="跌幅榜" rows={data.top_losers} mode="loss" activeSymbol={previewStock?.source === 'loss' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'loss', navList: stockListNav(data.top_losers) })} />
<StockList title="成交额榜" rows={data.turnover_leaders} mode="amount" activeSymbol={previewStock?.source === 'amount' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'amount', navList: stockListNav(data.turnover_leaders) })} />
<StockList title="活跃换手" rows={data.active_leaders} mode="active" activeSymbol={previewStock?.source === 'active' ? previewStock.symbol : undefined} onStockClick={(symbol, name) => setPreviewStock({ symbol, name, source: 'active', navList: stockListNav(data.active_leaders) })} />
</div>
</main>
@@ -887,9 +914,12 @@ export function Dashboard() {
<ArrowUpRight className="h-3.5 w-3.5" />
</Link>
</div>
<MonitorWidget onStockClick={(event) => {
if (event.symbol) setPreviewStock({ symbol: event.symbol, name: event.name ?? undefined, alert: event })
}} />
<MonitorWidget
activeSymbol={previewStock?.source === 'alert' ? previewStock.symbol : undefined}
onStockClick={(event, navList) => {
if (event.symbol) setPreviewStock({ symbol: event.symbol, name: event.name ?? undefined, alert: event, source: 'alert', navList })
}}
/>
</section>
</aside>
</div>
@@ -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)}
/>
<DimensionMembersDialog
+25 -15
View File
@@ -15,7 +15,7 @@ import {
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { AnalysisConfigDialog, DimensionHeatmap, PresetFetchState, type AnalysisFieldConfig } from '@/components/analysis-shared'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
import { RpsRotationDialog } from '@/components/RpsRotationDialog'
import { api, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
@@ -276,6 +276,7 @@ export function IndustryAnalysis() {
const [sortMode, setSortMode] = useState<SortMode>('heat')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
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}
/>
<IndustryFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
<IndustryFocus stat={selected} activeSymbol={previewSymbol} onStockClick={(sym, name, navList) => { setPreviewSymbol(sym); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} />
</div>
) : rowsQuery.isLoading ? (
<div className="rounded-2xl border border-border bg-surface px-6 py-16 text-center text-sm text-muted">...</div>
@@ -497,7 +499,9 @@ export function IndustryAnalysis() {
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => { setPreviewSymbol(null); setPreviewName('') }}
onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }}
navList={previewNavList}
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
/>
)}
{showRps && <RpsRotationDialog onClose={() => 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 (
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} />
<PulseList title="领涨主线" items={leading} mode="up" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
<PulseList title="领跌方向" items={falling} mode="down" selectedKey={selectedKey} onSelect={onSelect} onStockClick={onStockClick} activeSymbol={activeSymbol} />
</div>
)
}
@@ -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({
<div className="space-y-1">
{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 ? (
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { 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')}>
<span key={stock.symbol} title={stock.name || stock.symbol} onClick={e => { 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')}>
<span className="flex min-w-0 items-center gap-1">
<span className="min-w-0 truncate font-medium">{stock.name || stock.symbol}</span>
</span>
@@ -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 (
<section className="flex max-h-[720px] flex-col overflow-hidden rounded-2xl border border-border bg-surface">
<div className="shrink-0 border-b border-border px-5 py-4">
@@ -775,7 +785,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt
</div>
<div className="grid shrink-0 gap-3 border-b border-border bg-base/25 p-4 lg:grid-cols-[1fr_1.15fr]">
<LeaderStage stocks={topLeaders} onStockClick={onStockClick} />
<LeaderStage stocks={topLeaders} activeSymbol={activeSymbol} onStockClick={(sym, name) => onStockClick(sym, name, focusNav)} />
<ScoreExplain stock={topLeaders[0]} />
</div>
@@ -795,7 +805,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt
</thead>
<tbody className="divide-y divide-border/70">
{stocks.map((s, idx) => (
<tr key={`${s.symbol}-${idx}`} className="hover:bg-elevated/30 cursor-pointer" onClick={() => onStockClick(s.symbol, s.name || undefined)}>
<tr key={`${s.symbol}-${idx}`} className={cn('cursor-pointer', s.symbol === activeSymbol ? 'bg-accent/10 hover:bg-accent/15' : 'hover:bg-elevated/30')} onClick={() => onStockClick(s.symbol, s.name || undefined, focusNav)}>
<td className="px-4 py-2 font-mono text-muted">{idx + 1}</td>
<td className="px-4 py-2">
<div className="font-medium text-foreground">{s.name || '—'}</div>
@@ -826,7 +836,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st
return <div className="rounded-lg border border-border/60 bg-base/35 px-2 py-1.5"><div className="text-[10px] text-muted">{label}</div><div className={cn('mt-0.5 truncate text-sm font-semibold', cls)}>{value}</div></div>
}
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 <div className="rounded-xl border border-border/60 bg-surface p-4 text-sm text-muted"></div>
return (
<div className="rounded-xl border border-border/60 bg-surface p-3">
@@ -836,7 +846,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
</div>
<div className="grid gap-2 md:grid-cols-3">
{stocks.map((stock, idx) => (
<div key={stock.symbol} onClick={() => 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')}>
<div key={stock.symbol} onClick={() => 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')}>
<div className="flex items-center justify-between gap-2">
<span className={cn('text-[10px] font-medium', idx === 0 ? 'text-amber-300' : 'text-muted')}>{idx === 0 ? '主龙头' : `辅龙 ${idx}`}</span>
<span className="font-mono text-[11px] text-amber-300">{stock.leaderScore.toFixed(0)}</span>
+82 -47
View File
@@ -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<DOMRect | null>(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<string>
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<string, MonitorRule>
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,
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 px-3 pb-3">
{[...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 => (
<StockCard
key={`${s.symbol}-${s.status}`}
stock={s}
@@ -1131,6 +1148,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
hasDepth={hasDepth}
onClick={onStockClick}
onDimensionClick={onDimensionClick}
active={activeSymbol === s.symbol}
/>
))}
</div>
@@ -1490,6 +1508,7 @@ export function LimitUpLadder() {
}, [showConcept])
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState('')
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const [selectedTag, setSelectedTag] = useState<{ fieldKey: 'concept' | 'industry'; tag: string } | null>(null)
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(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}
/>
))}
</div>
@@ -1761,9 +1794,9 @@ export function LimitUpLadder() {
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => 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() {
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => setPreviewSymbol(null)}
onClose={() => { setPreviewSymbol(null); setPreviewNavList([]) }}
navList={previewNavList}
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
/>
{/* 字段配置弹窗 */}
+36 -6
View File
@@ -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<ReturnType<typeof setTimeout> | null>(null)
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const [memberPreview, setMemberPreview] = useState<{ symbol: string; name?: string } | null>(null)
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(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 (
<div className="space-y-3">
{alertsQuery.isLoading ? (
@@ -418,7 +435,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
onClick={() => handlePreviewEvent(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
@@ -497,7 +514,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
onClick={() => handlePreviewEvent(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
@@ -627,15 +644,18 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => { setPreviewEv(null); setMemberPreview(null) }}
navList={previewNavList}
onNavigate={handleNavigate}
onClose={() => { setPreviewEv(null); setMemberPreview(null); setPreviewNavList([]) }}
/>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
onStockClick={(symbol, name, navList) => {
setDimensionTarget(null)
setMemberPreview({ symbol, name })
setPreviewNavList(navList ?? alertsNavItems)
}}
/>
</div>
@@ -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 }: {
<StockPreviewDialog
symbol={previewSymbol}
name={previewSymbol ? symbolNames[previewSymbol] : undefined}
navList={rulesNavItems}
onNavigate={(sym) => setPreviewSymbol(sym)}
onClose={() => setPreviewSymbol(null)}
/>
</div>
+8 -2
View File
@@ -13,7 +13,7 @@ import { storage } from '@/lib/storage'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
import { StockPreviewDialog } 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 ?? '') }}
/>
<StrategySettingsDialog
+16 -3
View File
@@ -9,10 +9,11 @@ import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass, formatExtNumber } from '@/lib/format'
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 } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, toNavItems } from '@/components/StockPreviewDialog'
import {
DimensionMembersDialog,
dimensionKindForSourceField,
@@ -468,6 +469,7 @@ const StockCard = React.memo(function StockCard({
onToggleExpand,
onDimensionClick,
isMonitored,
active,
groups,
onToggleMember,
groupChangePending,
@@ -485,6 +487,8 @@ const StockCard = React.memo(function StockCard({
onToggleExpand: (key: string) => 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 (
<div
className={`relative rounded-lg border border-border bg-surface hover:border-border/80 transition-all duration-200 group cursor-pointer overflow-hidden ${bgGlow}`}
className={`relative rounded-lg border border-border bg-surface hover:border-border/80 transition-all duration-200 group cursor-pointer overflow-hidden ${bgGlow} ${active ? 'ring-2 ring-accent/60' : ''}`}
onClick={() => 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<HTMLDivElement>(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 ?? '') }}
/>
<DimensionMembersDialog