Merge pull request #210 from thinkbuf/feat/kline-preview-nav

feat(kline): 弹窗切股导航 + 邻股预取 + 悬停「至今」+ 外部链接
This commit is contained in:
wshy
2026-09-02 13:52:38 +08:00
committed by GitHub
21 changed files with 753 additions and 243 deletions
@@ -13,6 +13,7 @@ import {
import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { boardTag } from '@/components/stock-table/primitives'
import { toNavItems, type NavItem } from '@/components/StockPreviewDialog'
import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
@@ -39,7 +40,7 @@ export function dimensionKindForSourceField(sourceField: string): DimensionKind
interface Props {
target: DimensionMembersTarget | null
onClose: () => void
onStockClick?: (symbol: string, name?: string) => void
onStockClick?: (symbol: string, name?: string, navList?: NavItem[]) => void
}
interface ResolvedSource {
@@ -279,7 +280,7 @@ function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit<P
key={virtualRow.key}
ref={rowVirtualizer.measureElement}
data-index={virtualRow.index}
onClick={() => onStockClick?.(row.symbol, row.name)}
onClick={() => onStockClick?.(row.symbol, row.name, toNavItems(visibleRows))}
disabled={!onStockClick}
className="absolute left-0 top-0 grid min-h-[54px] w-full grid-cols-[minmax(132px,1fr)_74px_74px_18px] items-center border-b border-border/60 px-4 text-left text-xs transition-colors hover:bg-elevated/50 disabled:cursor-default md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]"
style={{ transform: `translateY(${virtualRow.start}px)` }}
+54 -28
View File
@@ -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<number>(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 = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;min-height:20px;flex-wrap:wrap">`
html += `<span style="color:${CT().text}">${d.date}</span>`
html += `<span style="color:${CT().text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
@@ -930,11 +933,25 @@ export function EChartsCandlestick({
html += `<span style="color:${CT().text}">换手</span>`
html += `<span style="color:${CT().text}">${turnoverRate.toFixed(2)}%</span>`
}
// 至今: 仅当竖虚线(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 += `<span style="color:${CT().text}">至今</span>`
html += `<span style="color:${sinceClr}">${fmtPct(sinceRatio)}</span>`
// 周期数: 从该K线(含)到最新一根K线共多少根; 悬停最后一根时为 1
html += `<span style="color:${CT().text}">周期 ${data.length - idx}</span>`
}
}
html += `</div>`
// 第二行: MA + BOLL
if (showMA) {
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;height:20px;flex-wrap:wrap">`
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;select:none;min-height:20px;flex-wrap:wrap">`
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
@@ -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 = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
let html = `<div style="display:flex;align-items:center;gap:6px;padding:0 8px;font:11px 'JetBrains Mono',monospace;min-height:20px;flex-wrap:wrap">`
html += `<span style="color:${CT().text}">${d.date}</span>`
html += `<span style="color:${CT().text}">开</span>`
html += `<span style="color:${d.open >= d.close ? THEME.bear : THEME.bull}">${d.open.toFixed(2)}</span>`
@@ -1182,7 +1208,7 @@ export function EChartsCandlestick({
}
html += `</div>`
if (showMA) {
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;height:20px;flex-wrap:wrap">`
html += `<div style="display:flex;align-items:center;gap:10px;padding:0 8px;font:11px 'JetBrains Mono',monospace;min-height:20px;flex-wrap:wrap">`
if (d.ma5 != null) html += `<span style="color:${THEME.ma5}">MA5:${Number(d.ma5).toFixed(2)}</span>`
if (d.ma10 != null) html += `<span style="color:${THEME.ma10}">MA10:${Number(d.ma10).toFixed(2)}</span>`
if (d.ma20 != null) html += `<span style="color:${THEME.ma20}">MA20:${Number(d.ma20).toFixed(2)}</span>`
+5 -33
View File
@@ -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 (
+42 -8
View File
@@ -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 (
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
{/* 首行 min-h-7 对齐完整态的 text-lg 价格行高, 加载中不整体变矮 */}
<div className="flex min-h-7 items-baseline gap-x-3 flex-wrap">
<span className="text-foreground font-bold text-sm tracking-wide">{symbol}</span>
{name && <span className="text-secondary font-medium">{name}</span>}
<span className="ml-auto self-center text-muted">
<span className="inline-block h-2.5 w-2.5 animate-spin rounded-full border-[1.5px] border-current border-t-transparent" />
</span>
</div>
{Array.from({ length: reserveLines }).map((_, i) => (
<div key={i} className="h-4" />
))}
</div>
)
}
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 (
<div className="px-2 pb-3 font-mono text-[12px] select-none space-y-1">
{/* Row 1: code, name, price, change, change% */}
@@ -230,8 +253,19 @@ export function StockInfoBar({
<span style={{ color: clr }} className="tabular-nums">
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
</span>
{/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
{/* 右侧操作按钮:外链 + 加自选 + 加监控 + 信息条配置 */}
<div className="ml-auto self-center flex items-center gap-1">
{extUrl && (
<a
href={extUrl}
target="_blank"
rel="noopener noreferrer"
title={extUrl}
className="p-1 rounded-btn text-muted hover:text-foreground hover:bg-elevated transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
)}
{inWatchlist && onRemoveFromWatchlist ? (
<button
type="button"
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Loader2 } from 'lucide-react'
import { api, type MinuteKlineRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { klineMinuteQueryOptions } from '@/lib/kline'
import { EChartsIntraday } from '@/components/EChartsIntraday'
interface Props {
@@ -35,10 +36,9 @@ export function StockIntradayChart({
const [minuteDismissed, setMinuteDismissed] = useState(false)
const minute = useQuery({
queryKey: QK.klineMinute(symbol, date ?? ''),
// 轮询上下文 (个股详情) 传 live: 当日盘中后端直接实时拉取最新K,
// 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。
queryFn: () => api.klineMinute(symbol, date ?? undefined, refetchIntervalMs != null),
...klineMinuteQueryOptions(symbol, date ?? undefined, refetchIntervalMs != null),
enabled: !!symbol && !!date,
refetchInterval: refetchIntervalMs,
})
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Download, Loader2, RefreshCw } from 'lucide-react'
import { api, type MinuteKlineSession } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { klineMinuteQueryOptions, klineMinuteRangeQueryOptions } from '@/lib/kline'
import { toast } from '@/components/Toast'
import { EChartsMultiDayIntraday } from '@/components/EChartsMultiDayIntraday'
@@ -29,16 +29,12 @@ export function StockMultiDayIntradayChart({
}: Props) {
const queryClient = useQueryClient()
const history = useQuery({
queryKey: QK.klineMinuteRange(symbol, days),
queryFn: () => api.klineMinuteRange(symbol, days),
...klineMinuteRangeQueryOptions(symbol, days),
enabled: !!symbol,
placeholderData: (previous, previousQuery) =>
previousQuery?.queryKey[1] === symbol ? previous : undefined,
})
const latest = useQuery({
queryKey: QK.klineMinute(symbol, ''),
// live: 当日盘中直接实时拉取, 不被分钟增量落盘的本地分区(≥60s一轮)拖慢
queryFn: () => api.klineMinute(symbol, undefined, true),
...klineMinuteQueryOptions(symbol, undefined, true),
enabled: !!symbol,
refetchInterval: refetchIntervalMs,
})
+61 -13
View File
@@ -1,10 +1,12 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { X } from 'lucide-react'
import { type KlineRow, type FinancialMetricRecord } from '@/lib/api'
import { klineDailyQueryOptions, klineMinuteQueryOptions, klineMinuteRangeQueryOptions, DEFAULT_INTRADAY_DAYS } from '@/lib/kline'
import { StockInfoBar } from '@/components/StockInfoBar'
import { StockDailyKChart, getDefaultRange, type StockDailyKChartResult } from '@/components/StockDailyKChart'
import { StockDailyKChart, getDefaultRange, toOHLC } from '@/components/StockDailyKChart'
import { StockIntradayChart } from '@/components/StockIntradayChart'
import { useFinancialMetrics } from '@/lib/useFinancials'
import { financialMetricsQueryOptions, useFinancialMetrics } from '@/lib/useFinancials'
import { useCapabilities } from '@/lib/useSharedQueries'
import type { ChartMarker, ChartPriceLine, ChartRange } from '@/components/EChartsCandlestick'
import {
@@ -40,6 +42,12 @@ interface Props {
refetchIntervalMs?: number
/** 只渲染信息条, 隐藏图表 (用于分时 tab 共享信息条) */
infoBarOnly?: boolean
/** 邻近预取目标 (切股导航的左右邻股): 提前拉取其日K/财务/分时缓存, 切换瞬间免 loading */
prefetchSymbols?: string[]
/** 多日分时周期 (分时 tab 使用): 预取邻股 klineMinuteRange 时用同一 days, 保证 queryKey 命中 */
intradayDays?: number
/** 日K/分时并排时日K图占宽 (默认 1:1; 弹窗内图表信息栏较宽需更多空间时传 flex-[1.4] 之类) */
dailyKlineFlex?: string
}
export { getDefaultRange }
@@ -64,11 +72,13 @@ export function StockPanel({
watchlistPending,
refetchIntervalMs,
infoBarOnly = false,
prefetchSymbols,
intradayDays = DEFAULT_INTRADAY_DAYS,
dailyKlineFlex = 'flex-1',
}: Props) {
const [linkedPrice, setLinkedPrice] = useState<number | null>(null)
const [selectedDate, setSelectedDate] = useState<string | null>(null)
const [intradayDismissed, setIntradayDismissed] = useState(false)
const [dailyResult, setDailyResult] = useState<StockDailyKChartResult | null>(null)
// 信息条指标配置提升到此层:同时供 StockInfoBar 渲染与 StockDailyKChart 请求 ext 数据
const [fields, setFields] = useState<ColumnConfig[]>(loadInfoFields)
const extColumns = useMemo(() => buildInfoExtColumnsParam(fields), [fields])
@@ -91,27 +101,66 @@ export function StockPanel({
const dateRange = externalDateRange ?? getDefaultRange()
// 日K查询由本组件持有 (与 StockDailyKChart 共享同一 cache key/配置, 只发一次请求)。
// 信息条直接读 query data: 切股到已预取邻股时首帧即有数据, 配合 StockInfoBar 加载态占位,
// 弹窗整体高度在切换瞬间不塌陷 (不抖动)。
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol })
const rawRows: KlineRow[] = kline.data?.rows ?? []
// OHLC 视图用于日期选中/昨收价推导 (与图表侧同口径)
const rows = useMemo(() => toOHLC(rawRows), [rawRows])
const stockInfo = kline.data?.stock_info
const name = kline.data?.name
const handleDateClick = useCallback((date: string) => {
setSelectedDate(date)
setIntradayDismissed(false)
onSelectDate?.(date)
}, [onSelectDate])
const rows = dailyResult?.rows ?? []
const stockInfo = dailyResult?.stockInfo
const rawRows: KlineRow[] = dailyResult?.rawRows ?? []
// 邻近预取: 对切股导航的左右邻股提前拉取缓存, 切股瞬间免 loading。
// 日K/分时预取 staleTime 30s 防来回切换重复请求; 成为当前股后 useQuery(staleTime=0) 立即后台刷新,
// SSE 也只按焦点股精准失效, 实时性不受影响。财务指标与正式查询同 staleTime, 5min 内不重复拉取。
// prefetchKey 按内容 join: 自选页 navList 随行情 tick 重建但邻股集合通常不变, 避免 effect 每次 tick 重跑。
const qc = useQueryClient()
const prefetchKey = prefetchSymbols?.join(',') ?? ''
// 守卫快速连续切股: 旧链路上异步回来的日K不再级联预取 (避免串股/浪费)
const prefetchTickRef = useRef('')
useEffect(() => {
if (!prefetchKey) return
prefetchTickRef.current = prefetchKey
for (const s of prefetchKey.split(',')) {
if (s === symbol) continue
if (hasFinanceField && hasFinancialCap) {
qc.prefetchQuery(financialMetricsQueryOptions(s))
}
// 分时 tab 的多日分时 + 最新分时: 切股后分时图也免 loading (与日K并行预取)
qc.prefetchQuery({ ...klineMinuteRangeQueryOptions(s, intradayDays), staleTime: 30_000 })
// latest 当日分时同样 live=true: 预取与渲染同读实时源 (历史日期后端忽略 live)
qc.prefetchQuery({ ...klineMinuteQueryOptions(s, undefined, true), staleTime: 30_000 })
// 日K用 fetchQuery (返回数据) 以便级联预取分时; 邻股预取失败静默, 不影响切股。
void qc.fetchQuery({ ...klineDailyQueryOptions(s, dateRange, extColumns), staleTime: 30_000 })
.then((res) => {
if (prefetchTickRef.current !== prefetchKey) return
// 日K到货后级联预取其默认选中日的分时数据: 日K视图并排展示分时图(默认选中最后交易日)。
const lastDate = res?.rows?.at(-1)?.date
if (lastDate) {
const d = String(lastDate).slice(0, 10)
// 同上 live=true: 该日若为当日即命中实时源 (历史日期后端忽略 live)
qc.prefetchQuery({ ...klineMinuteQueryOptions(s, d, true), staleTime: 30_000 })
}
})
.catch(() => {})
}
}, [prefetchKey, symbol, dateRange, extColumns, hasFinanceField, hasFinancialCap, intradayDays, qc])
// symbol 变化时重置分时相关状态,避免切股后残留旧日期。
// 注意:必须跳过首次挂载——重开弹窗时 kline 命中 react-query 缓存,
// 子组件 onDataChange effect(先于父 effect 执行)会把 dailyResult 置为有效数据,
// 若此处再无条件清空,会把刚加载的数据抹掉,导致信息条整行消失。
// 日K信息直接读 query data (切股到已预取邻股首帧即有), 无需清空或门控。
const prevSymbol = useRef<string | null>(symbol)
useEffect(() => {
if (prevSymbol.current === symbol) return
prevSymbol.current = symbol
setSelectedDate(null)
setLinkedPrice(null)
setDailyResult(null)
}, [symbol])
// 当分时开启、无选中日期时,自动选中最新日期
@@ -136,7 +185,7 @@ export function StockPanel({
<div className={className}>
<StockInfoBar
symbol={symbol}
name={dailyResult?.name}
name={name}
stockInfo={stockInfo}
rows={rawRows}
fields={fields}
@@ -154,7 +203,7 @@ export function StockPanel({
<StockDailyKChart
symbol={symbol}
height={height}
className="flex-1 min-w-0"
className={`${dailyKlineFlex} min-w-0`}
dateRange={dateRange}
markers={markers}
ranges={ranges}
@@ -164,7 +213,6 @@ export function StockPanel({
linkedPrice={linkedPrice}
onDateClick={handleDateClick}
onPriceDoubleClick={onPriceDoubleClick}
onDataChange={setDailyResult}
visibleBars={showIntraday ? 40 : 60}
extColumns={extColumns}
refetchIntervalMs={refetchIntervalMs}
+152 -11
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useMemo } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity } from 'lucide-react'
import { X, RefreshCw, Clock, LineChart, Star, RadioTower, Maximize2, Minimize2, Activity, ChevronLeft, ChevronRight } from 'lucide-react'
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { cn } from '@/lib/cn'
@@ -18,6 +18,7 @@ import { usePreferences } from '@/lib/useSharedQueries'
import { setFocusSymbol, clearFocusSymbol } from '@/lib/useQuoteStream'
import { useDialogBackdrop } from '@/lib/useDialogBackdrop'
import { storage } from '@/lib/storage'
import { DEFAULT_INTRADAY_DAYS } from '@/lib/kline'
import { ExtensionSlot } from '@/extensions/ExtensionSlot'
interface Props {
@@ -32,6 +33,35 @@ 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
}
/** 榜单里同一标的可能多次出现 (多概念/行业 leader、监控重复触发), 去重以免切股/计数空跳; 保留首次出现。 */
function uniqueNavItems(xs: NavItem[]): NavItem[] {
const seen = new Set<string>()
const out: NavItem[] = []
for (const n of xs) {
if (seen.has(n.symbol)) continue
seen.add(n.symbol)
out.push(n)
}
return out
}
// ===== 板块标识(与 Screener 列表一致)=====
@@ -50,11 +80,11 @@ interface PriceAlertDraft {
}
const INTRADAY_DAY_OPTIONS = [1, 5, 10, 20] as const
function loadIntradayDays(): number | null {
const saved = storage.stockPreviewIntradayDays.get(10)
function loadIntradayDays(): number {
const saved = storage.stockPreviewIntradayDays.get(DEFAULT_INTRADAY_DAYS)
return INTRADAY_DAY_OPTIONS.includes(saved as typeof INTRADAY_DAY_OPTIONS[number])
? saved
: null
: DEFAULT_INTRADAY_DAYS
}
function boardTag(symbol: string): { label: string; color: string } | null {
@@ -79,7 +109,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,18 +167,83 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
},
})
// ESC 关闭
// ===== 切股导航 =====
const navList = useMemo(() => uniqueNavItems(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])
// 邻近预取目标: 当前股左右相邻两只 (首↔尾循环), 交由 StockPanel 提前拉取日K/财务/分时缓存
const prefetchSymbols = useMemo(() => {
if (!navEnabled) return []
return [
navList[wrapNavIndex(navIdx, -1, navTotal)].symbol,
navList[wrapNavIndex(navIdx, 1, navTotal)].symbol,
]
}, [navEnabled, navIdx, navTotal, navList])
// 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') {
// 点位监控弹窗打开时方向键不切股 (与 ESC 的 !priceAlertDraft 守卫同层级)
if (priceAlertDraft) return
// 焦点在输入框/编辑器时方向键让位给光标/输入, 不切股
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()
// 切股后清掉控件残留的键盘焦点: 点过分时tab/外链等控件后方向键切股,
// 浏览器会给该控件显示 focus-visible 默认蓝色 outline, 切换后 blur 掉避免残留。
// keydown 的 e.target 即聚焦元素, 复用已捕获的 t (已排除输入框/编辑器)。
t?.blur()
}
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [symbol, onClose, priceAlertDraft])
}, [symbol, go, showMonitorEditor, priceAlertDraft])
// 弹窗内切股时保留当前视图 (分时 tab 下切股不应跳回日K);
// 仅当弹窗首次打开 (symbol 从 null 变非空) 时重置为日K。
const prevSymbolRef = useRef<string | null>(null)
useEffect(() => {
if (symbol) setView('daily')
if (prevSymbolRef.current == null && symbol != null) setView('daily')
prevSymbolRef.current = symbol
setPriceAlertDraft(null)
}, [symbol])
@@ -223,7 +318,7 @@ export function StockPreviewDialog({ symbol, name, onClose, triggerInfo }: Props
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className={cn(
'relative rounded-card border border-border bg-base shadow-2xl overflow-hidden flex flex-col transition-all duration-200 ease-smooth',
maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1100px] max-h-[95vh]',
maximized ? 'w-screen h-screen max-w-none max-h-none' : 'w-[92vw] max-w-[1200px] max-h-[95vh]',
)}
>
{/* 顶栏 */}
@@ -239,6 +334,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">
@@ -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}
/>
<StockMultiDayIntradayChart
symbol={symbol}
@@ -546,6 +672,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)
}}
/>
</>
+57
View File
@@ -0,0 +1,57 @@
/**
* 日K查询配置 — klineDaily 的唯一权威 options。
*
* StockDailyKChart(图表) 与 StockPanel(信息条) 各自 useQuery 共享同一 cache key,
* React Query 按 key 去重只发一次请求; 邻近预取 prefetchQuery 也复用本配置, 三处不会漂移。
*
* placeholderData 内置"仅同 symbol 占位"守卫: 改日期范围/扩展字段时旧数据可暂显(不闪),
* 切股时不透传上一只股票的数据(不误显示)。
*/
import { api } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
/** 分时 tab 多日分时默认周期 (StockPanel 预取与弹窗存储回退共用, 避免魔数两处漂移) */
export const DEFAULT_INTRADAY_DAYS = 10
export function klineDailyQueryOptions(
symbol: string,
dateRange: { start: string; end: string },
extColumns?: string,
) {
return {
queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
queryFn: () => api.klineDaily(symbol, undefined, dateRange, extColumns),
// 工厂无 TData 泛型, 参数用 any 以便 useQuery/prefetchQuery 共用
placeholderData: (prev: any, prevQuery: any) => {
const prevKey = prevQuery?.queryKey as readonly unknown[] | undefined
return prevKey?.[1] === symbol ? prev : undefined
},
}
}
/**
* 单日分时查询配置 — 与 klineDailyQueryOptions 同风格的单源 options (date 为空 = 最新日内)。
*
* live 仅当日盘中生效: 传 true 时后端实时拉取最新K, 不被分钟增量落盘(≥60s 一轮)拖慢;
* 历史日期后端自行忽略 live, 故多日图与预取恒传 true 亦不影响历史读取。
*/
export function klineMinuteQueryOptions(symbol: string, date?: string, live?: boolean) {
return {
queryKey: QK.klineMinute(symbol, date ?? ''),
queryFn: () => api.klineMinute(symbol, date ?? undefined, live),
}
}
/** 多日分时查询配置 — 分时 tab 的 StockMultiDayIntradayChart 与 邻近预取 共用。
* 内嵌「仅同 symbol 占位」守卫 (key 结构 ['kline-minute-range', symbol, days], index 1 为 symbol),
* 与 klineDailyQueryOptions 同源, 调用点不再各自手写。 */
export function klineMinuteRangeQueryOptions(symbol: string, days: number) {
return {
queryKey: QK.klineMinuteRange(symbol, days),
queryFn: () => api.klineMinuteRange(symbol, days),
placeholderData: (prev: any, prevQuery: any) => {
const prevKey = prevQuery?.queryKey as readonly unknown[] | undefined
return prevKey?.[1] === symbol ? prev : undefined
},
}
}
+36
View File
@@ -0,0 +1,36 @@
/**
* 个股详情外链 — 可配置 URL 模板 + 证券代码。
*
* 占位符保持独立, 用户自行排列 (市场前缀/后缀因站而异):
* {code} 纯 6 位代码, 如 000001
* {market} 市场前缀小写, 如 sz / sh / bj
* {symbol} 完整 symbol (含交易所后缀), 如 000001.SZ
* 模板留空 = 关闭外链。
*/
import { storage } from '@/lib/storage'
/** 形状守卫: 只放行 6位数字 + 沪深北后缀 的 symbol (信息条场景下即股票) */
const STOCK_SYMBOL_RE = /^(\d{6})\.(SH|SZ|BJ)$/
/** 未设置或留空 → 返回 '' 即关闭外链 */
export function loadStockExternalTemplate(): string {
return storage.stockExternalTemplate.get('')
}
export function saveStockExternalTemplate(tpl: string): void {
storage.stockExternalTemplate.set(tpl.trim())
}
export function buildStockExternalUrl(template: string, symbol: string): string | null {
if (!template) return null
// scheme 白名单: 只放行 http/https, 挡掉 javascript:/data: 等危险 scheme
if (!/^https?:\/\//i.test(template.trim())) return null
const m = symbol.match(STOCK_SYMBOL_RE)
if (!m) return null
const code = m[1]
const market = m[2].toLowerCase()
return template
.replaceAll('{code}', code)
.replaceAll('{market}', market)
.replaceAll('{symbol}', symbol)
}
+3
View File
@@ -44,6 +44,9 @@ export const storage = {
/** 个股详情多日分时周期 */
stockPreviewIntradayDays: kv<number>('stock_preview_intraday_days'),
/** 个股详情外链 URL 模板 (支持 {code}/{market}/{symbol}; 留空关闭) */
stockExternalTemplate: kv<string>('stock_external_template'),
/** 策略结果列表列配置 */
screenerResultColumns: kv<unknown[]>('screener_result_columns'),
+7 -4
View File
@@ -20,13 +20,16 @@ export function useFinancialStatus() {
})
}
export function useFinancialMetrics(symbol?: string) {
return useQuery({
export function financialMetricsQueryOptions(symbol?: string) {
return {
queryKey: FINANCIAL_QK.metrics(symbol),
queryFn: () => api.financialMetrics(symbol),
enabled: !!symbol,
staleTime: 300_000,
})
}
}
export function useFinancialMetrics(symbol?: string) {
return useQuery({ ...financialMetricsQueryOptions(symbol), enabled: !!symbol })
}
export function useFinancialIncome(symbol?: string) {
+31 -16
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
@@ -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,12 @@ 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 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 })
@@ -393,7 +399,8 @@ export function ConceptAnalysis() {
falling={falling}
selectedKey={selected?.key ?? null}
onSelect={setSelectedKey}
onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }}
activeSymbol={previewSymbol}
onStockClick={handleStockClick}
/>
{stats.length > 0 ? (
@@ -407,7 +414,7 @@ export function ConceptAnalysis() {
onSort={setSortMode}
onSelect={setSelectedKey}
/>
<ConceptFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
<ConceptFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</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 +440,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 +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 (
<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 +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({
<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 +611,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 +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 (
<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 +721,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 +741,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 +772,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 +782,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>
+62 -29
View File
@@ -10,7 +10,7 @@ import { fmtBigNum, fmtPct } from '@/lib/format'
import { DimensionMembersDialog, dimensionKindForSourceField, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
import { useDataStatus, useCapabilities, useSettings, usePreferences } from '@/lib/useSharedQueries'
import { SealedBadge } from '@/components/SealedBadge'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
import { SettingsModal } from '@/components/data/SettingsModal'
import { useAdjFactorSyncGate } from '@/components/AdjFactorSyncGate'
import { STAGE_LABELS } from '@/components/data/ActiveJobCard'
@@ -98,7 +98,10 @@ const _SEVERITY_BAR: Record<string, string> = {
info: 'bg-accent/40', warn: 'bg-warning', critical: 'bg-danger',
}
function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) => void }) {
function MonitorWidget({ onStockClick, activeSymbol }: {
onStockClick: (event: AlertEvent, navList?: NavItem[]) => void
activeSymbol?: string
}) {
const navigate = useNavigate()
const alerts = useQuery({
queryKey: ['alerts', ''],
@@ -106,6 +109,8 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) =>
refetchInterval: 10000,
})
const events: AlertEvent[] = alerts.data?.alerts ?? []
// 切股导航列表: 有 symbol 的触发记录
const alertNav = toNavItems(events.filter((ev): ev is AlertEvent & { symbol: string } => !!ev.symbol))
if (events.length === 0) {
return (
@@ -129,13 +134,13 @@ function MonitorWidget({ onStockClick }: { onStockClick: (event: AlertEvent) =>
initial={{ opacity: 0, y: -8, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3, delay: Math.min(i * 0.03, 0.3) }}
className="relative overflow-hidden rounded-md border border-border/40 bg-surface/60 pl-2.5 pr-2 py-1.5 hover:border-border hover:bg-surface transition-colors"
className={`relative overflow-hidden rounded-md border pl-2.5 pr-2 py-1.5 transition-colors ${ev.symbol && ev.symbol === activeSymbol ? 'border-accent/40 bg-accent/5' : 'border-border/40 bg-surface/60 hover:border-border hover:bg-surface'}`}
>
<div className={cn('absolute left-0 top-0 h-full w-0.5', sev)} />
{/* 第一行: 代码 + 名称 + 价格 + 涨跌幅 (点击代码/名称弹日K) */}
<div className="flex items-center gap-1.5">
<button
onClick={() => isSector ? navigate('/monitor') : ev.symbol && onStockClick(ev)}
onClick={() => isSector ? navigate('/monitor') : ev.symbol && onStockClick(ev, alertNav)}
title={isSector ? '在监控中心查看板块告警' : ev.symbol ? `查看 ${ev.symbol} 日K` : undefined}
className={`inline-flex items-center gap-1 min-w-0 shrink-0 rounded hover:bg-elevated/60 transition-colors -mx-0.5 px-0.5 ${isSector || ev.symbol ? 'cursor-pointer' : 'cursor-default'}`}
>
@@ -409,9 +414,10 @@ function MiniMetric({ label, value, cls = 'text-foreground' }: { label: string;
)
}
function StockList({ title, rows, mode, onStockClick }: {
function StockList({ title, rows, mode, onStockClick, activeSymbol }: {
title: string; rows: MarketSnapshotRow[]; mode: 'gain' | 'loss' | 'amount' | 'active';
onStockClick?: (symbol: string, name?: string) => void;
activeSymbol?: string;
}) {
return (
<div className="rounded-card border border-border bg-surface/80 p-1.5 shadow-[0_1px_2px_hsl(var(--border)/0.4)] backdrop-blur-sm transition-shadow hover:shadow-[0_2px_8px_hsl(var(--border)/0.5)]">
@@ -423,7 +429,7 @@ function StockList({ title, rows, mode, onStockClick }: {
{rows.slice(0, 8).map((r, idx) => (
<div
key={`${r.symbol}-${idx}`}
className="grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded-md bg-elevated/40 px-1.5 py-1 cursor-pointer hover:bg-elevated hover:brightness-110 transition-colors border border-transparent hover:border-border/60"
className={`grid grid-cols-[18px_1fr_auto] items-center gap-1.5 rounded-md px-1.5 py-1 cursor-pointer transition-colors border ${r.symbol === activeSymbol ? 'bg-accent/10 border-accent/30' : 'bg-elevated/40 border-transparent hover:bg-elevated hover:brightness-110 hover:border-border/60'}`}
onClick={() => onStockClick?.(r.symbol, r.name ?? undefined)}
>
<span className="text-center font-mono text-[10px] text-muted">{idx + 1}</span>
@@ -467,10 +473,11 @@ function StockList({ title, rows, mode, onStockClick }: {
)
}
function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
function RankColumn({ title, rows, tone, onStockClick, onDimensionClick, activeSymbol }: {
title: string; rows: OverviewDimensionRankItem[]; tone: 'bull' | 'bear';
onStockClick?: (symbol: string, name?: string) => void
onDimensionClick?: (target: DimensionMembersTarget) => void
onStockClick?: (symbol: string, name?: string) => void;
onDimensionClick?: (target: DimensionMembersTarget) => void;
activeSymbol?: string;
}) {
return (
<div className="min-w-0 space-y-1">
@@ -478,6 +485,7 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
{rows.slice(0, 5).map((r, idx) => {
const kind = r.source_field ? dimensionKindForSourceField(r.source_field) : null
const clickable = !!(r.source_field && kind && onDimensionClick)
const isActive = r.leader?.symbol != null && r.leader.symbol === activeSymbol
return (
<div
key={`${title}-${r.name}-${idx}`}
@@ -487,9 +495,9 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
sourceField: r.source_field!,
})}
title={clickable ? `查看「${r.name}」成分股` : undefined}
className={`grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md bg-elevated/40 px-1.5 py-1 border border-transparent transition-colors ${
clickable ? 'cursor-pointer hover:border-accent/40 hover:bg-elevated/70' : 'hover:border-border/60'
}`}
className={`grid grid-cols-[14px_1fr_auto] items-center gap-1 rounded-md px-1.5 py-1 border transition-colors ${
isActive ? 'border-accent/30 bg-accent/10' : 'border-transparent bg-elevated/40'
} ${clickable ? 'cursor-pointer hover:border-accent/40 hover:bg-elevated/70' : 'hover:border-border/60'}`}
>
<span className="text-center font-mono text-[9px] text-muted">{idx + 1}</span>
<div className="min-w-0">
@@ -533,10 +541,11 @@ function RankColumn({ title, rows, tone, onStockClick, onDimensionClick }: {
)
}
function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }: {
function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick, activeSymbol }: {
title: string; rank?: OverviewMarket['concept_rank']; configUrl: string;
onStockClick?: (symbol: string, name?: string) => void
onDimensionClick?: (target: DimensionMembersTarget) => void
onStockClick?: (symbol: string, name?: string) => void;
onDimensionClick?: (target: DimensionMembersTarget) => void;
activeSymbol?: string;
}) {
const hasData = (rank?.leading?.length ?? 0) > 0 || (rank?.lagging?.length ?? 0) > 0
return (
@@ -544,8 +553,8 @@ function HotRankCard({ title, rank, configUrl, onStockClick, onDimensionClick }:
<SectionTitle icon={Flame} title={title} hint="领涨/领跌 · 点击板块看成分股" />
{hasData ? (
<div className="grid grid-cols-2 gap-2">
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} onDimensionClick={onDimensionClick} />
<RankColumn title="领涨" rows={rank?.leading ?? []} tone="bull" onStockClick={onStockClick} onDimensionClick={onDimensionClick} activeSymbol={activeSymbol} />
<RankColumn title="领跌" rows={rank?.lagging ?? []} tone="bear" onStockClick={onStockClick} onDimensionClick={onDimensionClick} activeSymbol={activeSymbol} />
</div>
) : (
<div className="py-4 text-center">
@@ -562,11 +571,30 @@ 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[] {
const leaders = [...(rank?.leading ?? []), ...(rank?.lagging ?? [])]
.map(r => r.leader)
.filter((l): l is NonNullable<typeof l> & { symbol: string } => !!l?.symbol)
return toNavItems(leaders)
}
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 +883,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 +915,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 +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)}
/>
<DimensionMembersDialog
+31 -16
View File
@@ -1,4 +1,4 @@
import { useMemo, useState, type ReactNode } from 'react'
import { useCallback, useMemo, useState, type ReactNode } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { AnimatePresence } from 'framer-motion'
import {
@@ -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,12 @@ 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 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}
/>
<IndustryFocus stat={selected} onStockClick={(sym, name) => { setPreviewSymbol(sym); setPreviewName(name ?? '') }} />
<IndustryFocus stat={selected} activeSymbol={previewSymbol} onStockClick={handleStockClick} />
</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 +504,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 +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 (
<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 +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({
<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 +676,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 +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 (
<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 +790,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 +810,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 +841,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 +851,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>
+92 -51
View File
@@ -4,7 +4,7 @@ import { motion, AnimatePresence } from 'framer-motion'
import { RefreshCw, ChevronDown, Flame, Settings2, X, Bell, BellOff, AlertCircle } from 'lucide-react'
import { DatePicker } from '@/components/DatePicker'
import { api, type LimitLadderTier, type LimitLadderStock, type MonitorRule } from '@/lib/api'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
@@ -220,7 +220,7 @@ function useSealedDegrade(asOf: string, latestDate: string | undefined, sealedRe
// ===== 单只股票卡片 =====
const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick }: {
const StockCard = React.memo(function StockCard({ stock, extFields, direction, sealMode, monitored, monitorRule, onMonitorChange, hasDepth, onClick, onDimensionClick, active }: {
stock: LimitLadderStock
extFields: ExtFieldConfig
direction: Direction
@@ -231,6 +231,8 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
hasDepth: boolean
onClick: (symbol: string, name?: string) => void
onDimensionClick: (kind: DimensionKind, value: string, sourceField?: string) => void
/** 正在 K 线弹窗预览中 → 高亮卡片 */
active?: boolean
}) {
const [showMonitorMenu, setShowMonitorMenu] = useState(false)
const [menuAnchor, setMenuAnchor] = useState<DOMRect | null>(null)
@@ -298,7 +300,7 @@ const StockCard = React.memo(function StockCard({ stock, extFields, direction, s
event.preventDefault()
onClick(stock.symbol, stock.name ?? undefined)
}}
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''}`}
className={`w-full flex flex-col items-start gap-1 px-2.5 py-2 rounded-md transition-all duration-200 cursor-pointer hover:opacity-100 ${style.bg} ${style.bar} ${monitored ? 'ring-1 ring-amber-400/50 ring-inset' : ''} ${active ? 'ring-1 ring-accent/60 ring-inset' : ''}`}
style={style.cardStyle ? { ...style.cardStyle } : undefined}
onMouseEnter={e => {
if (!style.cardStyle || !style.hoverShadow) return
@@ -926,7 +928,53 @@ function TagStats({ title, tiers, extFields, fieldKey, color, selectedTag, onSel
// ===== 梯队分组 =====
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth }: {
/** 与 TierGroup 卡片展示一致的过滤+排序 (监控优先 → 状态 → 封单量), 供切股导航列表复用 */
function sortLadderStocks(
stocks: LimitLadderStock[],
opts: {
monitoredSymbols: Set<string>
sealMode: 'vol' | 'amount'
selectedTag: { fieldKey: 'concept' | 'industry'; tag: string } | null
extFields: ExtFieldConfig
},
): LimitLadderStock[] {
return [...stocks]
.filter(s => {
if (!opts.selectedTag) return true
const item = opts.extFields[opts.selectedTag.fieldKey]
if (!item) return true
const tags = getExtTags(s, item)
return tags.includes(opts.selectedTag.tag)
})
.sort((a, b) => {
// 开启监控的卡片排到分组最前
const ma = opts.monitoredSymbols.has(a.symbol) ? 0 : 1
const mb = opts.monitoredSymbols.has(b.symbol) ? 0 : 1
if (ma !== mb) return ma - mb
const ord = (s: string) => {
if (s === 'limit_up' || s === 'limit_down' || !s) return 0
if (s === 'broken' || s === 'recovery') return 1
return 2
}
const oa = ord(a.status ?? '')
const ob = ord(b.status ?? '')
if (oa !== ob) return oa - ob
// 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。
// 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。
if (oa === 0) {
const sealVal = (s: LimitLadderStock) => {
if (s.sealed_vol == null) return -1
return opts.sealMode === 'amount' && s.close
? s.sealed_vol * 100 * s.close
: s.sealed_vol
}
return sealVal(b) - sealVal(a)
}
return 0
})
}
function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick, selectedTag, onSelectTag, onDimensionClick, direction, sealMode, monitoredSymbols, ladderRules, onMonitorChange, hasDepth, activeSymbol }: {
tier: LimitLadderTier
defaultOpen: boolean
extFields: ExtFieldConfig
@@ -942,6 +990,8 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
ladderRules: Map<string, MonitorRule>
onMonitorChange: () => void
hasDepth: boolean
/** 正在 K 线弹窗预览中 → 高亮对应卡片 */
activeSymbol?: string | null
}) {
const isDarkTheme = useTheme() === 'dark'
const [open, setOpen] = useState(defaultOpen)
@@ -1085,40 +1135,7 @@ function TierGroup({ tier, defaultOpen, extFields, filterKeys, bf, onStockClick,
</div>
)}
<div className="grid grid-cols-[repeat(auto-fill,minmax(140px,1fr))] gap-3 px-3 pb-3">
{[...tier.stocks]
.filter(s => {
if (!selectedTag) return true
const item = extFields[selectedTag.fieldKey]
if (!item) return true
const tags = getExtTags(s, item)
return tags.includes(selectedTag.tag)
})
.sort((a, b) => {
// 开启监控的卡片排到分组最前
const ma = monitoredSymbols.has(a.symbol) ? 0 : 1
const mb = monitoredSymbols.has(b.symbol) ? 0 : 1
if (ma !== mb) return ma - mb
const ord = (s: string) => {
if (s === 'limit_up' || s === 'limit_down' || !s) return 0
if (s === 'broken' || s === 'recovery') return 1
return 2
}
const oa = ord(a.status ?? '')
const ob = ord(b.status ?? '')
if (oa !== ob) return oa - ob
// 同状态(主状态=涨停/跌停)内: 按封单从高到低排, 无封单排末尾。
// 封单额 = sealed_vol(手) × 100 × close, 与展示口径一致。
if (oa === 0) {
const sealVal = (s: typeof a) => {
if (s.sealed_vol == null) return -1
return sealMode === 'amount' && s.close
? s.sealed_vol * 100 * s.close
: s.sealed_vol
}
return sealVal(b) - sealVal(a)
}
return 0
}).map(s => (
{tier.stocks.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,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() {
<TagStats
title="概念分布"
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
extFields={resolvedExtFields}
fieldKey="concept"
color={{ text: [250, 204, 21], textLight: [161, 98, 7], bg: [234, 179, 8] }}
selectedTag={selectedTag}
@@ -1724,7 +1762,7 @@ export function LimitUpLadder() {
<TagStats
title="行业分布"
tiers={tiers}
extFields={resolveExtFields(extFields, showConcept, showIndustry)}
extFields={resolvedExtFields}
fieldKey="industry"
color={{ text: [96, 165, 250], textLight: [29, 78, 216], bg: [59, 130, 246] }}
selectedTag={selectedTag}
@@ -1736,12 +1774,12 @@ export function LimitUpLadder() {
{/* 梯队列表 */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-2">
{tiers.map(t => (
{sortedTiers.map(t => (
<TierGroup
key={t.boards}
tier={t}
defaultOpen={t.boards >= 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}
/>
))}
</div>
@@ -1761,9 +1800,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 +1810,9 @@ export function LimitUpLadder() {
<StockPreviewDialog
symbol={previewSymbol}
name={previewName}
onClose={() => setPreviewSymbol(null)}
onClose={() => { setPreviewSymbol(null); setPreviewNavList([]) }}
navList={previewNavList}
onNavigate={(sym, n) => { setPreviewSymbol(sym); setPreviewName(n ?? '') }}
/>
{/* 字段配置弹窗 */}
+36 -6
View File
@@ -1,4 +1,4 @@
import { useState, useRef, useEffect, useMemo } from 'react'
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { motion, AnimatePresence } from 'framer-motion'
@@ -17,7 +17,7 @@ import { boardTag } from '@/components/stock-table/primitives'
import { resolveWatchlistGroupColor } from '@/lib/watchlist-group-colors'
import { markSeen, resetBadge, leaveMonitorPage } from '@/lib/monitorBadge'
import { RuleEditor } from '@/components/monitor/RuleEditor'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, toNavItems, type NavItem } from '@/components/StockPreviewDialog'
import { DimensionMembersDialog, type DimensionKind, type DimensionMembersTarget } from '@/components/DimensionMembersDialog'
import { usePreferences, useQuoteStatus } from '@/lib/useSharedQueries'
@@ -339,6 +339,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [previewEv, setPreviewEv] = useState<AlertEvent | null>(null)
const [memberPreview, setMemberPreview] = useState<{ symbol: string; name?: string } | null>(null)
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
const clearMut = useMutation({
@@ -367,6 +368,22 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const events = (alertsQuery.data as any)?.alerts ?? []
// 切股导航列表: 有 symbol 的触发记录 (按展示顺序)
const alertsNavItems = useMemo(
() => toNavItems(events.filter((ev: AlertEvent) => ev.symbol)),
[events],
)
const handlePreviewEvent = useCallback((ev: AlertEvent) => {
setPreviewEv(ev)
setPreviewNavList(alertsNavItems)
}, [alertsNavItems])
// 弹窗内切股: 来自成分弹窗则更新 memberPreview, 否则按 symbol 找到对应事件 (保住 triggerInfo)
const handleNavigate = useCallback((sym: string, name?: string) => {
if (memberPreview) { setMemberPreview({ symbol: sym, name }); return }
const ev = events.find((e: AlertEvent) => e.symbol === sym)
if (ev) setPreviewEv(ev)
}, [memberPreview, events])
return (
<div className="space-y-3">
{alertsQuery.isLoading ? (
@@ -418,7 +435,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
onClick={() => handlePreviewEvent(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
@@ -497,7 +514,7 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
const board = boardTag(ev.symbol)
return (
<button
onClick={() => setPreviewEv(ev)}
onClick={() => handlePreviewEvent(ev)}
className="inline-flex items-center gap-1.5 rounded hover:bg-elevated/50 px-1 -mx-1 transition-colors cursor-pointer"
title="点击查看日K"
>
@@ -627,15 +644,18 @@ function AlertsList({ alertsQuery, confirmClear, setConfirmClear, total, enterTs
signals: previewEv.signals,
message: previewEv.message,
} : null}
onClose={() => { setPreviewEv(null); setMemberPreview(null) }}
navList={previewNavList}
onNavigate={handleNavigate}
onClose={() => { setPreviewEv(null); setMemberPreview(null); setPreviewNavList([]) }}
/>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
onStockClick={(symbol, name, navList) => {
setDimensionTarget(null)
setMemberPreview({ symbol, name })
setPreviewNavList(navList ?? alertsNavItems)
}}
/>
</div>
@@ -695,6 +715,14 @@ function RulesList({ rulesQuery, onEdit }: {
})
const symbolNames = namesQuery.data?.names ?? {}
// 切股导航列表: 个股规则 (取第一个 symbol, 按展示顺序)
const rulesNavItems = useMemo(
() => rules
.filter(r => r.scope === 'symbols' && r.symbols.length > 0)
.map(r => ({ symbol: r.symbols[0], name: symbolNames[r.symbols[0]] ?? undefined })),
[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>
+11 -3
View File
@@ -13,7 +13,7 @@ import { storage } from '@/lib/storage'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { DatePicker } from '@/components/DatePicker'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, 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<string>('')
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
const closePreview = useCallback(() => { setPreviewSymbol(null); setPreviewName('') }, [])
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const closePreview = useCallback(() => {
setPreviewSymbol(null)
setPreviewName('')
setPreviewNavList([])
}, [])
const [settingsStrategyId, setSettingsStrategyId] = useState<string | null>(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 ?? '') }}
/>
<StrategySettingsDialog
+23 -4
View File
@@ -9,10 +9,11 @@ import { fetchMinuteBatchIncremental } from '@/lib/minuteBatchIncremental'
import { QK } from '@/lib/queryKeys'
import { storage } from '@/lib/storage'
import { fmtPrice, fmtPct, fmtBigNum, priceColorClass, formatExtNumber } from '@/lib/format'
import { cn } from '@/lib/cn'
import { computeGroupPcts, loadGroupStatsConfig, type GroupStatsConfigPatch } from '@/lib/watchlistGroupStats'
import { PageHeader } from '@/components/PageHeader'
import { EmptyState } from '@/components/EmptyState'
import { StockPreviewDialog } from '@/components/StockPreviewDialog'
import { StockPreviewDialog, toNavItems, type NavItem } 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 ?? '')}
>
{/* 左侧彩色指示条 */}
@@ -776,11 +780,14 @@ export function Watchlist() {
}, [])
const [previewSymbol, setPreviewSymbol] = useState<string | null>(null)
const [previewName, setPreviewName] = useState<string>('')
// 切股导航: 默认用 previewNavItems(自选列表); 从成分弹窗打开时用成分列表覆盖
const [previewNavList, setPreviewNavList] = useState<NavItem[]>([])
const [dimensionTarget, setDimensionTarget] = useState<DimensionMembersTarget | null>(null)
const [expandedCells, setExpandedCells] = useState<Set<string>>(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<HTMLDivElement>(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 ?? '') }}
/>
<DimensionMembersDialog
target={dimensionTarget}
onClose={() => setDimensionTarget(null)}
onStockClick={(symbol, name) => {
onStockClick={(symbol, name, navList) => {
setDimensionTarget(null)
setPreviewSymbol(symbol)
setPreviewName(name ?? '')
// 成分列表作为切股导航 (成员可能不在自选列表, 不能退回 previewNavItems)
setPreviewNavList(navList ?? previewNavItems)
}}
/>
+27 -1
View File
@@ -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() {
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<ExternalLink className="h-4 w-4 text-accent" />
<h3 className="text-sm font-medium text-foreground"></h3>
</div>
<div className="flex items-center justify-between gap-4 py-2">
<div className="min-w-0">
<div className="text-sm text-foreground"> URL </div>
<div className="text-[11px] text-muted truncate">{"支持 {code} {market} {symbol} · 留空关闭外链"}</div>
</div>
<input
value={extTpl}
onChange={(e) => {
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"
/>
</div>
</section>
<section className="rounded-card border border-border bg-surface p-5 mt-6">
<div className="flex items-center gap-2 mb-4">
<Trash2 className="h-4 w-4 text-accent" />