diff --git a/frontend/src/components/DimensionMembersDialog.tsx b/frontend/src/components/DimensionMembersDialog.tsx index d67ef6f..95db97b 100644 --- a/frontend/src/components/DimensionMembersDialog.tsx +++ b/frontend/src/components/DimensionMembersDialog.tsx @@ -13,6 +13,7 @@ import { import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react' import { Modal } from '@/components/Modal' import { boardTag } from '@/components/stock-table/primitives' +import { toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api' import { QK } from '@/lib/queryKeys' import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format' @@ -39,7 +40,7 @@ export function dimensionKindForSourceField(sourceField: string): DimensionKind interface Props { target: DimensionMembersTarget | null onClose: () => void - onStockClick?: (symbol: string, name?: string) => void + onStockClick?: (symbol: string, name?: string, navList?: NavItem[]) => void } interface ResolvedSource { @@ -279,7 +280,7 @@ function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit

onStockClick?.(row.symbol, row.name)} + onClick={() => onStockClick?.(row.symbol, row.name, toNavItems(visibleRows))} disabled={!onStockClick} className="absolute left-0 top-0 grid min-h-[54px] w-full grid-cols-[minmax(132px,1fr)_74px_74px_18px] items-center border-b border-border/60 px-4 text-left text-xs transition-colors hover:bg-elevated/50 disabled:cursor-default md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]" style={{ transform: `translateY(${virtualRow.start}px)` }} diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx index 63035fd..3bebb57 100644 --- a/frontend/src/components/EChartsCandlestick.tsx +++ b/frontend/src/components/EChartsCandlestick.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useCallback, useMemo } from 'react' import { chartTheme, getTheme, useTheme } from '@/lib/theme' +import { fmtPct } from '@/lib/format' import * as echarts from 'echarts' import type { ECharts, EChartsOption } from 'echarts' @@ -832,6 +833,8 @@ export function EChartsCandlestick({ const infoIdxRef = useRef(data.length - 1) const compactRef = useRef(false) const userZoomRef = useRef<{ start: number; end: number } | null>(null) + // 竖虚线(crosshair)是否可见: 控制信息栏「至今」字段的显隐。鼠标移出图表区即 false。 + const hoverActiveRef = useRef(false) // 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值 const activeIndicatorsRef = useRef(activeIndicators) @@ -911,7 +914,7 @@ export function EChartsCandlestick({ const floatShares = stockInfo?.float_shares const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null - let html = `

` + let html = `
` html += `${d.date}` html += `` html += `${d.open.toFixed(2)}` @@ -930,11 +933,25 @@ export function EChartsCandlestick({ html += `换手` html += `${turnoverRate.toFixed(2)}%` } + // 至今: 仅当竖虚线(crosshair)在图上且鼠标悬停某根 K 线时显示。 + // 最新价取最后一根K线收盘 (后端 _maybe_inject_live_candle 盘中注入实时价, 收盘后即最近收盘)。 + // 基准取该K线昨收(前一日收盘), 与同花顺及全市场涨幅口径一致; 数据第一根K线无昨收则跳过。 + if (hoverActiveRef.current && prev && Number.isFinite(prev.close) && prev.close > 0) { + const latestPrice = data[data.length - 1].close + if (Number.isFinite(latestPrice)) { + const sinceRatio = (latestPrice - prev.close) / prev.close + const sinceClr = sinceRatio >= 0 ? THEME.bull : THEME.bear + html += `至今` + html += `${fmtPct(sinceRatio)}` + // 周期数: 从该K线(含)到最新一根K线共多少根; 悬停最后一根时为 1 + html += `周期 ${data.length - idx}` + } + } html += `
` // 第二行: MA + BOLL if (showMA) { - html += `
` + html += `
` if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` @@ -949,12 +966,17 @@ export function EChartsCandlestick({ }, [data, stockInfo, showMA, activeIndicators]) getInfoBarHTMLRef.current = getInfoBarHTML - // data 变化时重置 infoIdx + // data/symbol 变化时重置 infoIdx: + // symbol(_symbol) 进依赖是必要的——预取切股到同长度邻股时 data.length 不变, + // 但悬停上下文来自上一只股票, 必须清掉 hoverActiveRef 以免「至今/周期」残留显示。 + // (同一股的实时刷新 symbol 不变, 不触发, 悬停位置与「至今」保持实时) useEffect(() => { infoIdxRef.current = data.length - 1 compactRef.current = false userZoomRef.current = null - }, [data.length]) + // 新数据无悬停上下文, 隐藏「至今」; 下次鼠标移动时由 updateAxisPointer 重新置位 + hoverActiveRef.current = false + }, [_symbol, data.length]) // ===== 初始化 chart (只在 chartHeight 变化时重建) ===== useEffect(() => { @@ -965,32 +987,36 @@ export function EChartsCandlestick({ chartRef.current = chart // 鼠标移动 → 只更新 ref + DOM,不触发 React re-render - // 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏 + // 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏; 鼠标移出时仅隐藏「至今」。 chart.on('updateAxisPointer', (event: any) => { const axesInfo = event.axesInfo - if (!axesInfo) return // 鼠标移出图表区域,保持当前显示 - for (const info of Object.values(axesInfo)) { - const val = (info as any)?.value - if (val == null) continue - const d = dataRef.current - const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val) - if (idx >= 0 && idx < d.length) { - if (infoIdxRef.current === idx) return - infoIdxRef.current = idx - - // 直接更新信息栏 DOM (通过 ref 读取最新的生成函数) - const infoEl = infoBarRef.current - if (infoEl) { - const html = getInfoBarHTMLRef.current() - if (html) infoEl.innerHTML = html // 只在有内容时更新 - } - - // 更新子图 graphic - triggerInfoBarUpdate() - return + const d = dataRef.current + // 竖虚线是否正落在某根有效 K 线上 (鼠标在图表数据区内) + let foundIdx = -1 + if (axesInfo) { + for (const info of Object.values(axesInfo)) { + const val = (info as any)?.value + if (val == null) continue + const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val) + if (idx >= 0 && idx < d.length) { foundIdx = idx; break } } } - // 没有找到有效数据 — 不做任何操作,保持上次显示 + const active = foundIdx >= 0 + const idxChanged = foundIdx >= 0 && infoIdxRef.current !== foundIdx + const visChanged = active !== hoverActiveRef.current + hoverActiveRef.current = active + if (idxChanged) infoIdxRef.current = foundIdx + // 竖虚线显隐或悬停 K 线变化 → 重绘一次信息栏 (控制「至今」字段显隐 + 当前 K 线数据) + if (visChanged || idxChanged) { + const infoEl = infoBarRef.current + if (infoEl) { + const html = getInfoBarHTMLRef.current() + if (html) infoEl.innerHTML = html // 只在有内容时更新 + } + } + if (foundIdx < 0) return + // 更新子图 graphic (仅悬停 K 线变化时; 纯显隐切换不影响副图) + if (idxChanged) triggerInfoBarUpdate() }) chart.on('click', (params: any) => { @@ -1159,7 +1185,7 @@ export function EChartsCandlestick({ if (!d) return '' const floatShares = stockInfo?.float_shares const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null - let html = `
` + let html = `
` html += `${d.date}` html += `` html += `${d.open.toFixed(2)}` @@ -1182,7 +1208,7 @@ export function EChartsCandlestick({ } html += `
` if (showMA) { - html += `
` + html += `
` if (d.ma5 != null) html += `MA5:${Number(d.ma5).toFixed(2)}` if (d.ma10 != null) html += `MA10:${Number(d.ma10).toFixed(2)}` if (d.ma20 != null) html += `MA20:${Number(d.ma20).toFixed(2)}` diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx index 495ecc4..142a1a5 100644 --- a/frontend/src/components/StockDailyKChart.tsx +++ b/frontend/src/components/StockDailyKChart.tsx @@ -1,7 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { api, type KlineRow } from '@/lib/api' -import { QK } from '@/lib/queryKeys' +import { type KlineRow } from '@/lib/api' +import { klineDailyQueryOptions } from '@/lib/kline' import { storage } from '@/lib/storage' import { EChartsCandlestick, @@ -11,13 +11,11 @@ import { type ChartPriceLine, type ChartRange, type OHLC, - type StockInfo, type VolumeCompareConfig, } from '@/components/EChartsCandlestick' const SUB_INFO_H = 16 const SUB_GAP = 4 -const MAX_DAYS = 2000 const DEFAULT_VOLUME_COMPARE: VolumeCompareConfig = { enabled: true, days: 1 } function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfig { @@ -27,13 +25,6 @@ function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfi } } -export interface StockDailyKChartResult { - rows: OHLC[] - rawRows: KlineRow[] - stockInfo?: StockInfo - name?: string -} - interface Props { symbol: string height?: number @@ -51,7 +42,6 @@ interface Props { linkedPrice?: number | null onDateClick?: (date: string) => void onPriceDoubleClick?: (price: number, currentPrice: number) => void - onDataChange?: (result: StockDailyKChartResult) => void /** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */ extColumns?: string /** 日K自动刷新间隔(ms)。undefined = 不轮询(默认)。个股对话框实时刷新时传入, 盘中今日蜡烛随之更新 */ @@ -113,12 +103,6 @@ export function getDefaultRange(): { start: string; end: string } { return { start, end } } -function rangeDays(range: { start: string; end: string }): number { - const start = new Date(range.start) - const end = new Date(range.end) - return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS) -} - export function StockDailyKChart({ symbol, height = 520, @@ -136,7 +120,6 @@ export function StockDailyKChart({ linkedPrice, onDateClick, onPriceDoubleClick, - onDataChange, extColumns, refetchIntervalMs, }: Props) { @@ -146,16 +129,9 @@ export function StockDailyKChart({ normalizeVolumeCompare(storage.stockVolumeCompare.get(DEFAULT_VOLUME_COMPARE)), ) const dateRange = externalDateRange ?? getDefaultRange() - const days = useMemo(() => rangeDays(dateRange), [dateRange]) - // extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数) - const kline = useQuery({ - queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns), - queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns), - enabled: !!symbol, - refetchInterval: refetchIntervalMs, - placeholderData: (prev) => prev, - }) + // 查询配置统一来自 klineDailyQueryOptions, 与 StockPanel 信息条/邻近预取共享同一 cache key (只发一次请求) + const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol, refetchInterval: refetchIntervalMs }) const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows]) const stockInfo = kline.data?.stock_info @@ -185,10 +161,6 @@ export function StockDailyKChart({ if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14 const chartHeight = height + subExtraH - useEffect(() => { - onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name }) - }, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo]) - if (!symbol) return null return ( diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx index 1e2d6c8..7f01ebd 100644 --- a/frontend/src/components/StockInfoBar.tsx +++ b/frontend/src/components/StockInfoBar.tsx @@ -1,10 +1,11 @@ import { useState, type ReactNode } from 'react' -import { Settings2, RadioTower, Star } from 'lucide-react' +import { Settings2, RadioTower, Star, ExternalLink } from 'lucide-react' import type { KlineRow, FinancialMetricRecord } from '@/lib/api' import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format' import { ListColumnCustomizer } from '@/components/ListColumnCustomizer' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields' +import { buildStockExternalUrl, loadStockExternalTemplate } from '@/lib/stock-external-link' const BULL = '#C74040' const BEAR = '#2D9B65' @@ -122,7 +123,32 @@ export function StockInfoBar({ }) } - if (rows.length === 0) return null + // 字段分组: 加载态预留高度与完整态渲染共用同一规则 + const visibleFields = fields.filter(f => f.visible) + const inlineFields = visibleFields.filter(f => !f.standalone) + const standaloneFields = visibleFields.filter(f => f.standalone) + + // 无数据时保持信息条挂载 (切股/首次加载): 只留 symbol+名称+小 spinner 作为加载态, + // 不渲染假占位值; 数据到位后价格/市值等原位填充, 避免整行消失造成布局跳动。 + // 同时按字段配置预留与完整态相同的行数, 切股瞬间弹窗整体高度不塌陷 (不抖动)。 + if (rows.length === 0) { + const reserveLines = (inlineFields.length > 0 ? 1 : 0) + standaloneFields.length + return ( +
+ {/* 首行 min-h-7 对齐完整态的 text-lg 价格行高, 加载中不整体变矮 */} +
+ {symbol} + {name && {name}} + + + +
+ {Array.from({ length: reserveLines }).map((_, i) => ( +
+ ))} +
+ ) + } const latest = rows[rows.length - 1] const prev = rows.length >= 2 ? rows[rows.length - 2] : null @@ -183,11 +209,6 @@ export function StockInfoBar({ } } - const visibleFields = fields.filter(f => f.visible) - // 按是否单独显示分组:普通列共一行,standalone 列各占一行 - const inlineFields = visibleFields.filter(f => !f.standalone) - const standaloneFields = visibleFields.filter(f => f.standalone) - // 渲染单个字段(builtin / ext 通用) const renderField = (f: ColumnConfig): ReactNode => { if (f.source.type === 'ext') { @@ -215,6 +236,8 @@ export function StockInfoBar({ ) } + const extUrl = buildStockExternalUrl(loadStockExternalTemplate(), symbol) + return (
{/* Row 1: code, name, price, change, change% */} @@ -230,8 +253,19 @@ export function StockInfoBar({ {isUp ? '+' : ''}{fmtPrice(chgPct)}% - {/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */} + {/* 右侧操作按钮:外链 + 加自选 + 加监控 + 信息条配置 */}
+ {extUrl && ( + + + + )} {inWatchlist && onRemoveFromWatchlist ? ( + + {navIdx + 1} / {navTotal} + + + + )}
@@ -491,6 +612,9 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props priceLines={monitorPriceLines} onPriceDoubleClick={openPriceAlert} refetchIntervalMs={intradayRefetchMs} + prefetchSymbols={prefetchSymbols} + intradayDays={effectiveIntradayDays} + dailyKlineFlex="flex-[1.4]" /> ) : ( <> @@ -498,6 +622,8 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props symbol={symbol} dateRange={dateRange} infoBarOnly + prefetchSymbols={prefetchSymbols} + intradayDays={effectiveIntradayDays} /> )} + + {/* 首↔尾循环弱提示 */} + + {wrapMsg && ( + + {wrapMsg} + + )} +
)} diff --git a/frontend/src/components/screener/ScreenerTable.tsx b/frontend/src/components/screener/ScreenerTable.tsx index 09439c7..e908da6 100644 --- a/frontend/src/components/screener/ScreenerTable.tsx +++ b/frontend/src/components/screener/ScreenerTable.tsx @@ -22,6 +22,8 @@ import { dimensionKindForSourceField, type DimensionMembersTarget, } from '@/components/DimensionMembersDialog' +import { toNavItems, type NavItem } from '@/components/StockPreviewDialog' +import { cn } from '@/lib/cn' interface ScreenerTableProps { rows: any[] @@ -30,7 +32,7 @@ interface ScreenerTableProps { symbolStrategyMap: Map activeStrategy: string | null watchlistSet: Set - onPreview: (symbol: string, name: string) => void + onPreview: (symbol: string, name?: string, navList?: NavItem[]) => void onAddToWatchlist: (symbol: string, groupId: string | null) => void onRemoveFromWatchlist: (symbol: string) => void watchlistPending: boolean @@ -57,6 +59,8 @@ interface ScreenerTableProps { /** 表头排序(受控,由 Screener.tsx 传入) */ sort?: SortState | null onSortToggle?: (colId: string) => void + /** 正在 K 线弹窗预览中的 symbol → 高亮该行 */ + activeSymbol?: string | null } /** 渲染标签数组(含 maxTags 折叠/展开、横竖排列)。策略列与 ext 列共用。 @@ -161,7 +165,7 @@ export function ScreenerTable({ minuteData = {}, intradayChartVisible = true, onToggleIntradayChart, intradayAutoRefresh = false, onRefreshIntraday, intradayRefreshing = false, strategyTagsExpanded = false, onToggleStrategyTags, - sort, onSortToggle, + sort, onSortToggle, activeSymbol, }: ScreenerTableProps) { const [expandedCells, setExpandedCells] = useState>(new Set()) const [dimensionTarget, setDimensionTarget] = useState(null) @@ -244,7 +248,7 @@ export function ScreenerTable({
) : rowsQuery.isLoading ? (
正在计算概念强度...
@@ -433,7 +440,9 @@ export function ConceptAnalysis() { { setPreviewSymbol(null); setPreviewName('') }} + onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }} + navList={previewNavList} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> )} @@ -509,17 +518,19 @@ function MarketPulse({ selectedKey, onSelect, onStockClick, + activeSymbol, }: { leading: ConceptStat[] falling: ConceptStat[] selectedKey: string | null onSelect: (key: string) => void - onStockClick: (symbol: string, name?: string) => void + onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void + activeSymbol: string | null }) { return (
- - + +
) } @@ -531,13 +542,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 +569,8 @@ function PulseList({
{items.map((item, idx) => { const active = selectedKey === item.key - const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3) + const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore) + const leaders = sortedStocks.slice(0, 3) const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0 const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0 const flatPct = Math.max(0, 100 - upPct - downPct) @@ -597,7 +611,7 @@ function PulseList({ {Array.from({ length: 3 }).map((_, i) => { const stock = leaders[i] return stock ? ( - { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}> + { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}> {stock.name || stock.symbol} @@ -674,10 +688,11 @@ function ConceptRail({ ) } -function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string) => void }) { +function ConceptFocus({ stat, onStockClick, activeSymbol }: { stat: ConceptStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) { if (!stat) return null const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS) const topLeaders = stocks.slice(0, 3) + const focusNav: NavItem[] = toNavItems(stocks) return (
@@ -706,7 +721,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc
- + onStockClick(sym, name, focusNav)} />
@@ -726,7 +741,7 @@ function ConceptFocus({ stat, onStockClick }: { stat: ConceptStat | null; onStoc {stocks.map((s, idx) => ( - onStockClick(s.symbol, s.name || undefined)}> + onStockClick(s.symbol, s.name || undefined, focusNav)}> {idx + 1}
{s.name || '—'}
@@ -757,7 +772,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st return
{label}
{value}
} -function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) { +function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) { if (!stocks.length) return
暂无龙头候选
return (
@@ -767,7 +782,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
{stocks.map((stock, idx) => ( -
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}> +
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
{idx === 0 ? '主龙头' : `辅龙 ${idx}`} {stock.leaderScore.toFixed(0)} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 702968a..e418927 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -10,7 +10,7 @@ import { fmtBigNum, fmtPct } from '@/lib/format' import { DimensionMembersDialog, dimensionKindForSourceField, type DimensionMembersTarget } from '@/components/DimensionMembersDialog' import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries' import { SealedBadge } from '@/components/SealedBadge' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { SettingsModal } from '@/components/data/SettingsModal' import { useAdjFactorSyncGate } from '@/components/AdjFactorSyncGate' import { STAGE_LABELS } from '@/components/data/ActiveJobCard' @@ -98,7 +98,10 @@ const _SEVERITY_BAR: Record = { info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger', } -function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => void }) { +function MonitorWidget({ onStockClick, activeSymbol }: { + onStockClick: (event: AlertEvent, navList?: NavItem[]) => void + activeSymbol?: string +}) { const navigate = useNavigate() const alerts = useQuery({ queryKey: ['alerts', ''], @@ -106,6 +109,8 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => refetchInterval: 10000, }) const events: AlertEvent[] = alerts.data?.alerts ?? [] + // 切股导航列表: 有 symbol 的触发记录 + const alertNav = toNavItems(events.filter((ev): ev is AlertEvent & { symbol: string } => !!ev.symbol)) if (events.length === 0) { return ( @@ -129,13 +134,13 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => initial={{ opacity: 0, y: -8, scale: 0.98 }} animate={{ opacity: 1, y: 0, scale: 1 }} transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }} - className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors" + className={`relative overflow-hidden rounded-md border pl-2.5 pr-2 py-1.5 transition-colors ${ev.symbol && ev.symbol === activeSymbol ? 'border-accent/40 bg-accent/5' : 'border-border/40 bg-surface/60 hover:border-border hover:bg-surface'}`} >
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
@@ -904,6 +935,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: undefined } : prev)} onClose={() => setPreviewStock(null)} /> ('heat') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + const [previewNavList, setPreviewNavList] = useState([]) + const handleStockClick = useCallback((symbol: string, name?: string, navList?: NavItem[]) => { + setPreviewSymbol(symbol) + setPreviewName(name ?? '') + setPreviewNavList(navList ?? []) + }, []) const [showRps, setShowRps] = useState(false) const configsQuery = useQuery({ queryKey: QK.extData, queryFn: api.extDataList }) @@ -446,7 +452,8 @@ export function IndustryAnalysis() { falling={falling} selectedKey={selected?.key ?? null} onSelect={setSelectedKey} - onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} + activeSymbol={previewSymbol} + onStockClick={handleStockClick} /> {/* 热力图 */} @@ -471,7 +478,7 @@ export function IndustryAnalysis() { onSort={setSortMode} onSelect={setSelectedKey} /> - { setPreviewSymbol(sym); setPreviewName(name ?? '') }} /> +
) : rowsQuery.isLoading ? (
正在计算行业强度...
@@ -497,7 +504,9 @@ export function IndustryAnalysis() { { setPreviewSymbol(null); setPreviewName('') }} + onClose={() => { setPreviewSymbol(null); setPreviewName(''); setPreviewNavList([]) }} + navList={previewNavList} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> )} {showRps && setShowRps(false)} kind="industry" />} @@ -574,17 +583,19 @@ function MarketPulse({ selectedKey, onSelect, onStockClick, + activeSymbol, }: { leading: IndustryStat[] falling: IndustryStat[] selectedKey: string | null onSelect: (key: string) => void - onStockClick: (symbol: string, name?: string) => void + onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void + activeSymbol: string | null }) { return (
- - + +
) } @@ -596,13 +607,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 +634,8 @@ function PulseList({
{items.map((item, idx) => { const active = selectedKey === item.key - const leaders = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, 3) + const sortedStocks = [...item.stocks].sort((a, b) => b.leaderScore - a.leaderScore) + const leaders = sortedStocks.slice(0, 3) const upPct = item.count > 0 ? (item.upCount / item.count) * 100 : 0 const downPct = item.count > 0 ? (item.downCount / item.count) * 100 : 0 const flatPct = Math.max(0, 100 - upPct - downPct) @@ -662,7 +676,7 @@ function PulseList({ {Array.from({ length: 3 }).map((_, i) => { const stock = leaders[i] return stock ? ( - { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary')}> + { e.stopPropagation(); onStockClick(stock.symbol, stock.name || undefined, toNavItems(sortedStocks.slice(0, MAX_RENDERED_STOCKS))) }} className={cn('flex min-w-0 items-center gap-1 rounded-md px-1.5 py-0.5 text-[10px] cursor-pointer hover:brightness-125', i === 0 ? 'bg-amber-300/10 text-foreground' : 'bg-elevated/60 text-secondary', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}> {stock.name || stock.symbol} @@ -743,10 +757,11 @@ function IndustryRail({ // ===== IndustryFocus(右侧聚焦面板) ===== -function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string) => void }) { +function IndustryFocus({ stat, onStockClick, activeSymbol }: { stat: IndustryStat | null; onStockClick: (symbol: string, name?: string, navList?: NavItem[]) => void; activeSymbol: string | null }) { if (!stat) return null const stocks = [...stat.stocks].sort((a, b) => b.leaderScore - a.leaderScore).slice(0, MAX_RENDERED_STOCKS) const topLeaders = stocks.slice(0, 3) + const focusNav: NavItem[] = toNavItems(stocks) return (
@@ -775,7 +790,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt
- + onStockClick(sym, name, focusNav)} />
@@ -795,7 +810,7 @@ function IndustryFocus({ stat, onStockClick }: { stat: IndustryStat | null; onSt {stocks.map((s, idx) => ( - onStockClick(s.symbol, s.name || undefined)}> + onStockClick(s.symbol, s.name || undefined, focusNav)}> {idx + 1}
{s.name || '—'}
@@ -826,7 +841,7 @@ function MiniStat({ label, value, cls }: { label: string; value: string; cls: st return
{label}
{value}
} -function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void }) { +function LeaderStage({ stocks, onStockClick, activeSymbol }: { stocks: EnrichedStock[]; onStockClick: (symbol: string, name?: string) => void; activeSymbol: string | null }) { if (!stocks.length) return
暂无龙头候选
return (
@@ -836,7 +851,7 @@ function LeaderStage({ stocks, onStockClick }: { stocks: EnrichedStock[]; onStoc
{stocks.map((stock, idx) => ( -
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35')}> +
onStockClick(stock.symbol, stock.name || undefined)} className={cn('rounded-lg border p-3 cursor-pointer hover:brightness-110 transition-all', idx === 0 ? 'border-amber-400/25 bg-amber-400/[0.06]' : 'border-border/60 bg-base/35', stock.symbol === activeSymbol && 'ring-1 ring-accent/60')}>
{idx === 0 ? '主龙头' : `辅龙 ${idx}`} {stock.leaderScore.toFixed(0)} diff --git a/frontend/src/pages/LimitUpLadder.tsx b/frontend/src/pages/LimitUpLadder.tsx index 41de236..6e12ad2 100644 --- a/frontend/src/pages/LimitUpLadder.tsx +++ b/frontend/src/pages/LimitUpLadder.tsx @@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion' import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle } from 'lucide-react' import { DatePicker } from '@/components/DatePicker' import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog' import { QK } from '@/lib/queryKeys' import { storage } from '@/lib/storage' @@ -220,7 +220,7 @@ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedRe // ===== 单只股票卡片 ===== -const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick }: { +const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick, active }: { stock: LimitLadderStock extFields: ExtFieldConfig direction: Direction @@ -231,6 +231,8 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s hasDepth: boolean onClick: (symbol: string, name?: string) => void onDimensionClick: (kind: DimensionKind, value: string, sourceField?: string) => void + /** 正在 K 线弹窗预览中 → 高亮卡片 */ + active?: boolean }) { const [showMonitorMenu, setShowMonitorMenu] = useState(false) const [menuAnchor, setMenuAnchor] = useState(null) @@ -298,7 +300,7 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s event.preventDefault() onClick(stock.symbol, stock.name ?? undefined) }} - className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`} + className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''} ${active ? 'ring-1 ring-accent/60 ring-inset' : ''}`} style={style.cardStyle ? { ...style.cardStyle } : undefined} onMouseEnter={e => { if (!style.cardStyle || !style.hoverShadow) return @@ -926,7 +928,53 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel // ===== 梯队分组 ===== -function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: { +/** 与 TierGroup 卡片展示一致的过滤+排序 (监控优先 → 状态 → 封单量), 供切股导航列表复用 */ +function sortLadderStocks( + stocks: LimitLadderStock[], + opts: { + monitoredSymbols: Set + sealMode: 'vol' | 'amount' + selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null + extFields: ExtFieldConfig + }, +): LimitLadderStock[] { + return [...stocks] + .filter(s => { + if (!opts.selectedTag) return true + const item = opts.extFields[opts.selectedTag.fieldKey] + if (!item) return true + const tags = getExtTags(s, item) + return tags.includes(opts.selectedTag.tag) + }) + .sort((a, b) => { + // 开启监控的卡片排到分组最前 + const ma = opts.monitoredSymbols.has(a.symbol) ? 0 : 1 + const mb = opts.monitoredSymbols.has(b.symbol) ? 0 : 1 + if (ma !== mb) return ma - mb + const ord = (s: string) => { + if (s === 'limit_up' || s === 'limit_down' || !s) return 0 + if (s === 'broken' || s === 'recovery') return 1 + return 2 + } + const oa = ord(a.status ?? '') + const ob = ord(b.status ?? '') + if (oa !== ob) return oa - ob + // 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。 + // 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。 + if (oa === 0) { + const sealVal = (s: LimitLadderStock) => { + if (s.sealed_vol == null) return -1 + return opts.sealMode === 'amount' && s.close + ? s.sealed_vol * 100 * s.close + : s.sealed_vol + } + return sealVal(b) - sealVal(a) + } + return 0 + }) +} + +function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth, activeSymbol }: { tier: LimitLadderTier defaultOpen: boolean extFields: ExtFieldConfig @@ -942,6 +990,8 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, ladderRules: Map onMonitorChange: () => void hasDepth: boolean + /** 正在 K 线弹窗预览中 → 高亮对应卡片 */ + activeSymbol?: string | null }) { const isDarkTheme = useTheme() === 'dark' const [open, setOpen] = useState(defaultOpen) @@ -1085,40 +1135,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
)}
- {[...tier.stocks] - .filter(s => { - if (!selectedTag) return true - const item = extFields[selectedTag.fieldKey] - if (!item) return true - const tags = getExtTags(s, item) - return tags.includes(selectedTag.tag) - }) - .sort((a, b) => { - // 开启监控的卡片排到分组最前 - const ma = monitoredSymbols.has(a.symbol) ? 0 : 1 - const mb = monitoredSymbols.has(b.symbol) ? 0 : 1 - if (ma !== mb) return ma - mb - const ord = (s: string) => { - if (s === 'limit_up' || s === 'limit_down' || !s) return 0 - if (s === 'broken' || s === 'recovery') return 1 - return 2 - } - const oa = ord(a.status ?? '') - const ob = ord(b.status ?? '') - if (oa !== ob) return oa - ob - // 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。 - // 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。 - if (oa === 0) { - const sealVal = (s: typeof a) => { - if (s.sealed_vol == null) return -1 - return sealMode === 'amount' && s.close - ? s.sealed_vol * 100 * s.close - : s.sealed_vol - } - return sealVal(b) - sealVal(a) - } - return 0 - }).map(s => ( + {tier.stocks.map(s => ( ))}
@@ -1490,6 +1508,7 @@ export function LimitUpLadder() { }, [showConcept]) const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + const [previewNavList, setPreviewNavList] = useState([]) const [selectedTag, setSelectedTag] = useState<{ fieldKey: 'concept' | 'industry'; tag: string } | null>(null) const [dimensionTarget, setDimensionTarget] = useState(null) const handleSelectTag = useCallback((sel: { fieldKey: 'concept' | 'industry'; tag: string } | null) => { @@ -1511,11 +1530,6 @@ export function LimitUpLadder() { storage.limitLadderExtFields.set(f) }, []) - const handleStockClick = useCallback((symbol: string, name?: string) => { - setPreviewSymbol(symbol) - setPreviewName(name ?? '') - }, []) - const extColumnsParam = useMemo(() => buildExtColumnsParam(extFields), [extFields]) const { data, isLoading, refetch, isFetching } = useQuery({ @@ -1532,9 +1546,33 @@ 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 + // 单源: 梯队按展示同款过滤+排序一次 (监控优先 → 状态 → 封单量), + // 卡片渲染(TierGroup)与切股导航(ladderNavItems)共用, 避免每 tick 二次排序。 + const resolvedExtFields = useMemo( + () => resolveExtFields(extFields, showConcept, showIndustry), + [extFields, showConcept, showIndustry], + ) + const sortedTiers = useMemo( + () => tiers.map(t => ({ ...t, stocks: sortLadderStocks(t.stocks, { monitoredSymbols, sealMode, selectedTag, extFields: resolvedExtFields }) })), + [tiers, monitoredSymbols, sealMode, selectedTag, resolvedExtFields], + ) + + // 切股导航列表: 由 sortedTiers 展平 (顺序 = 卡片展示顺序) + const ladderNavItems = useMemo( + () => toNavItems(sortedTiers.flatMap(t => t.stocks)), + [sortedTiers], + ) + + 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) @@ -1710,7 +1748,7 @@ export function LimitUpLadder() { - {tiers.map(t => ( + {sortedTiers.map(t => ( = 1 || t.count <= 8} - extFields={resolveExtFields(extFields, showConcept, showIndustry)} + extFields={resolvedExtFields} filterKeys={filterKeys} bf={extFields.bf} onStockClick={handleStockClick} @@ -1754,6 +1792,7 @@ export function LimitUpLadder() { ladderRules={ladderRules} onMonitorChange={refetchMonitorRules} hasDepth={sealedDegrade.hasDepth} + activeSymbol={previewSymbol} /> ))}
@@ -1761,9 +1800,9 @@ export function LimitUpLadder() { setDimensionTarget(null)} - onStockClick={(symbol, name) => { + onStockClick={(symbol, name, navList) => { setDimensionTarget(null) - handleStockClick(symbol, name) + handleStockClick(symbol, name, navList) }} /> @@ -1771,7 +1810,9 @@ export function LimitUpLadder() { setPreviewSymbol(null)} + onClose={() => { setPreviewSymbol(null); setPreviewNavList([]) }} + navList={previewNavList} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> {/* 字段配置弹窗 */} diff --git a/frontend/src/pages/Monitor.tsx b/frontend/src/pages/Monitor.tsx index a7e1a19..d6f8905 100644 --- a/frontend/src/pages/Monitor.tsx +++ b/frontend/src/pages/Monitor.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useEffect, useMemo } from 'react' +import { useState, useRef, useEffect, useMemo, useCallback } from 'react' import { useNavigate, useSearchParams, Link } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { motion, AnimatePresence } from 'framer-motion' @@ -17,7 +17,7 @@ import { boardTag } from '@/components/stock-table/primitives' import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors' import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge' import { RuleEditor } from '@/components/monitor/RuleEditor' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog' import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog' import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries' @@ -339,6 +339,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs const resetTimer = useRef | null>(null) const [previewEv, setPreviewEv] = useState(null) const [memberPreview, setMemberPreview] = useState<{ symbol: string; name?: string } | null>(null) + const [previewNavList, setPreviewNavList] = useState([]) const [dimensionTarget, setDimensionTarget] = useState(null) const clearMut = useMutation({ @@ -367,6 +368,22 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs const events = (alertsQuery.data as any)?.alerts ?? [] + // 切股导航列表: 有 symbol 的触发记录 (按展示顺序) + const alertsNavItems = useMemo( + () => toNavItems(events.filter((ev: AlertEvent) => ev.symbol)), + [events], + ) + const handlePreviewEvent = useCallback((ev: AlertEvent) => { + setPreviewEv(ev) + setPreviewNavList(alertsNavItems) + }, [alertsNavItems]) + // 弹窗内切股: 来自成分弹窗则更新 memberPreview, 否则按 symbol 找到对应事件 (保住 triggerInfo) + const handleNavigate = useCallback((sym: string, name?: string) => { + if (memberPreview) { setMemberPreview({ symbol: sym, name }); return } + const ev = events.find((e: AlertEvent) => e.symbol === sym) + if (ev) setPreviewEv(ev) + }, [memberPreview, events]) + return (
{alertsQuery.isLoading ? ( @@ -418,7 +435,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs const board = boardTag(ev.symbol) return (
@@ -695,6 +715,14 @@ function RulesList({ rulesQuery, onEdit }: { }) const symbolNames = namesQuery.data?.names ?? {} + // 切股导航列表: 个股规则 (取第一个 symbol, 按展示顺序) + const rulesNavItems = useMemo( + () => rules + .filter(r => r.scope === 'symbols' && r.symbols.length > 0) + .map(r => ({ symbol: r.symbols[0], name: symbolNames[r.symbols[0]] ?? undefined })), + [rules, symbolNames], + ) + const del = useMutation({ mutationFn: api.monitorRuleDelete, onSuccess: () => qc.invalidateQueries({ queryKey: QK.monitorRules }), @@ -930,6 +958,8 @@ function RulesList({ rulesQuery, onEdit }: { setPreviewSymbol(sym)} onClose={() => setPreviewSymbol(null)} />
diff --git a/frontend/src/pages/Screener.tsx b/frontend/src/pages/Screener.tsx index c7f09ee..af6f19e 100644 --- a/frontend/src/pages/Screener.tsx +++ b/frontend/src/pages/Screener.tsx @@ -13,7 +13,7 @@ import { storage } from '@/lib/storage' import { PageHeader } from '@/components/PageHeader' import { EmptyState } from '@/components/EmptyState' import { DatePicker } from '@/components/DatePicker' -import { StockPreviewDialog } from '@/components/StockPreviewDialog' +import { StockPreviewDialog, type NavItem } from '@/components/StockPreviewDialog' import { WatchlistAddMenu } from '@/components/WatchlistAddMenu' import { useStrategyPool } from '@/lib/useStrategyPool' import { StrategyCard, CardSize, loadCardSize, cardWrapCls } from '@/components/screener/StrategyCard' @@ -50,7 +50,12 @@ export function Screener() { const [batchMsg, setBatchMsg] = useState('') const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') - const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, []) + const [previewNavList, setPreviewNavList] = useState([]) + const closePreview = useCallback(() => { + setPreviewSymbol(null) + setPreviewName('') + setPreviewNavList([]) + }, []) const [settingsStrategyId, setSettingsStrategyId] = useState(null) const [showPoolDialog, setShowPoolDialog] = useState(false) const [showBuilder, setShowBuilder] = useState(false) @@ -982,8 +987,9 @@ export function Screener() { strategyIdToName={strategyIdToName} symbolStrategyMap={symbolStrategyMap} activeStrategy={activeStrategy} + activeSymbol={previewSymbol} watchlistSet={watchlistSet} - onPreview={(symbol, name) => { setPreviewSymbol(symbol); setPreviewName(name) }} + onPreview={(symbol, name, navList) => { setPreviewSymbol(symbol); setPreviewName(name ?? ''); setPreviewNavList(navList ?? []) }} onAddToWatchlist={(symbol, groupId) => toggleWatchlist.mutate({ symbol, action: 'add', groupId })} onRemoveFromWatchlist={symbol => toggleWatchlist.mutate({ symbol, action: 'remove' })} watchlistPending={toggleWatchlist.isPending} @@ -1035,6 +1041,8 @@ export function Screener() { symbol={previewSymbol} name={previewName} onClose={closePreview} + navList={previewNavList} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> void onDimensionClick: (target: DimensionMembersTarget) => void isMonitored?: boolean + /** 正在 K 线弹窗预览中 → 高亮卡片 */ + active?: boolean groups: WatchlistGroup[] onToggleMember: (symbol: string, groupId: string, member: boolean) => void groupChangePending: boolean @@ -510,7 +514,7 @@ const StockCard = React.memo(function StockCard({ return (
onPreview(r.symbol, name ?? '')} > {/* 左侧彩色指示条 */} @@ -776,11 +780,14 @@ export function Watchlist() { }, []) const [previewSymbol, setPreviewSymbol] = useState(null) const [previewName, setPreviewName] = useState('') + // 切股导航: 默认用 previewNavItems(自选列表); 从成分弹窗打开时用成分列表覆盖 + const [previewNavList, setPreviewNavList] = useState([]) const [dimensionTarget, setDimensionTarget] = useState(null) const [expandedCells, setExpandedCells] = useState>(new Set()) const closePreview = useCallback(() => { setPreviewSymbol(null) setPreviewName('') + setPreviewNavList([]) }, []) const handleToggleExpand = useCallback((cellKey: string) => { @@ -1255,6 +1262,13 @@ export function Watchlist() { [filteredRows, sortRows, columns], ) + // 切股导航列表: 按列表当前展示顺序 (与 sortedRows 一致, 排序/筛选后行序随之变化)。 + // 弹窗未打开时跳过构建 — sortedRows 随行情 tick 重建, 避免无谓分配。 + const previewNavItems = useMemo( + () => previewSymbol ? toNavItems(sortedRows) : [], + [previewSymbol, sortedRows], + ) + const cardColumns = useCardColumnCount() const cardGridRef = useRef(null) const virtualizeCards = viewMode === 'card' && !groupCardsOpen && sortedRows.length > VIRTUAL_LIST_THRESHOLD @@ -1341,6 +1355,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 +1683,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,15 +2005,19 @@ export function Watchlist() { symbol={previewSymbol} name={previewName} onClose={closePreview} + navList={previewNavList.length > 0 ? previewNavList : previewNavItems} + onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }} /> setDimensionTarget(null)} - onStockClick={(symbol, name) => { + onStockClick={(symbol, name, navList) => { setDimensionTarget(null) setPreviewSymbol(symbol) setPreviewName(name ?? '') + // 成分列表作为切股导航 (成员可能不在自选列表, 不能退回 previewNavItems) + setPreviewNavList(navList ?? previewNavItems) }} /> diff --git a/frontend/src/pages/settings/System.tsx b/frontend/src/pages/settings/System.tsx index 54b5e75..7b0f657 100644 --- a/frontend/src/pages/settings/System.tsx +++ b/frontend/src/pages/settings/System.tsx @@ -5,7 +5,7 @@ */ import { useState, useCallback, useEffect } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info } from 'lucide-react' +import { Settings2, Trash2, RefreshCw, Bell, Volume2, Info, ExternalLink } from 'lucide-react' import { usePreferences, useVersion } from '@/lib/useSharedQueries' import { api } from '@/lib/api' import { QK } from '@/lib/queryKeys' @@ -15,6 +15,7 @@ import { SOUND_OPTIONS, previewSound } from '@/lib/notificationSound' import { listZhVoices, previewVoice, activateVoice, getCurrentVoiceURI, } from '@/lib/voiceBroadcast' +import { loadStockExternalTemplate, saveStockExternalTemplate } from '@/lib/stock-external-link' export function SettingsSystemPanel() { const qc = useQueryClient() @@ -23,6 +24,7 @@ export function SettingsSystemPanel() { const [saving, setSaving] = useState(false) const screenerAutoRun = prefs?.screener_auto_run ?? true + const [extTpl, setExtTpl] = useState(() => loadStockExternalTemplate()) const [clearing, setClearing] = useState(false) const [toastEnabled, setToastEnabled] = useState(() => { try { return localStorage.getItem('alert_toast_enabled') !== '0' } catch { return true } @@ -288,6 +290,30 @@ export function SettingsSystemPanel() {
+
+
+ +

个股详情外链

+
+ +
+
+
详情页 URL 模板
+
{"支持 {code} {market} {symbol} · 留空关闭外链"}
+
+ { + setExtTpl(e.target.value) + saveStockExternalTemplate(e.target.value) + }} + placeholder="https://..." + spellCheck={false} + className="w-[26rem] max-w-[60%] h-8 px-2.5 rounded-btn border border-border bg-base text-xs font-mono text-foreground focus:border-accent/50 focus:outline-none" + /> +
+
+