mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
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:
@@ -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)` }}
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ?? '') }}
|
||||
/>
|
||||
|
||||
{/* 字段配置弹窗 */}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user