diff --git a/frontend/src/components/DimensionMembersDialog.tsx b/frontend/src/components/DimensionMembersDialog.tsx
index d67ef6f..95db97b 100644
--- a/frontend/src/components/DimensionMembersDialog.tsx
+++ b/frontend/src/components/DimensionMembersDialog.tsx
@@ -13,6 +13,7 @@ import {
import { Activity, Building2, ChevronRight, Database, RefreshCw, Search, Tags, Users, X } from 'lucide-react'
import { Modal } from '@/components/Modal'
import { boardTag } from '@/components/stock-table/primitives'
+import { toNavItems, type NavItem } from '@/components/StockPreviewDialog'
import { api, type DimensionIntradayPoint, type MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
@@ -39,7 +40,7 @@ export function dimensionKindForSourceField(sourceField: string): DimensionKind
interface Props {
target: DimensionMembersTarget | null
onClose: () => void
- onStockClick?: (symbol: string, name?: string) => void
+ onStockClick?: (symbol: string, name?: string, navList?: NavItem[]) => void
}
interface ResolvedSource {
@@ -279,7 +280,7 @@ function DimensionMembersDialogContent({ target, onClose, onStockClick }: Omit
onStockClick?.(row.symbol, row.name)}
+ onClick={() => onStockClick?.(row.symbol, row.name, toNavItems(visibleRows))}
disabled={!onStockClick}
className="absolute left-0 top-0 grid min-h-[54px] w-full grid-cols-[minmax(132px,1fr)_74px_74px_18px] items-center border-b border-border/60 px-4 text-left text-xs transition-colors hover:bg-elevated/50 disabled:cursor-default md:grid-cols-[minmax(180px,1fr)_90px_84px_88px_100px_18px]"
style={{ transform: `translateY(${virtualRow.start}px)` }}
diff --git a/frontend/src/components/EChartsCandlestick.tsx b/frontend/src/components/EChartsCandlestick.tsx
index 63035fd..3bebb57 100644
--- a/frontend/src/components/EChartsCandlestick.tsx
+++ b/frontend/src/components/EChartsCandlestick.tsx
@@ -1,5 +1,6 @@
import { useEffect, useRef, useCallback, useMemo } from 'react'
import { chartTheme, getTheme, useTheme } from '@/lib/theme'
+import { fmtPct } from '@/lib/format'
import * as echarts from 'echarts'
import type { ECharts, EChartsOption } from 'echarts'
@@ -832,6 +833,8 @@ export function EChartsCandlestick({
const infoIdxRef = useRef(data.length - 1)
const compactRef = useRef(false)
const userZoomRef = useRef<{ start: number; end: number } | null>(null)
+ // 竖虚线(crosshair)是否可见: 控制信息栏「至今」字段的显隐。鼠标移出图表区即 false。
+ const hoverActiveRef = useRef(false)
// 需要在闭包中访问最新值的变量 — 先声明占位,后面赋值
const activeIndicatorsRef = useRef(activeIndicators)
@@ -911,7 +914,7 @@ export function EChartsCandlestick({
const floatShares = stockInfo?.float_shares
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
- let html = ``
+ let html = `
`
html += `${d.date}`
html += `开`
html += `${d.open.toFixed(2)}`
@@ -930,11 +933,25 @@ export function EChartsCandlestick({
html += `换手`
html += `${turnoverRate.toFixed(2)}%`
}
+ // 至今: 仅当竖虚线(crosshair)在图上且鼠标悬停某根 K 线时显示。
+ // 最新价取最后一根K线收盘 (后端 _maybe_inject_live_candle 盘中注入实时价, 收盘后即最近收盘)。
+ // 基准取该K线昨收(前一日收盘), 与同花顺及全市场涨幅口径一致; 数据第一根K线无昨收则跳过。
+ if (hoverActiveRef.current && prev && Number.isFinite(prev.close) && prev.close > 0) {
+ const latestPrice = data[data.length - 1].close
+ if (Number.isFinite(latestPrice)) {
+ const sinceRatio = (latestPrice - prev.close) / prev.close
+ const sinceClr = sinceRatio >= 0 ? THEME.bull : THEME.bear
+ html += `至今`
+ html += `${fmtPct(sinceRatio)}`
+ // 周期数: 从该K线(含)到最新一根K线共多少根; 悬停最后一根时为 1
+ html += `周期 ${data.length - idx}`
+ }
+ }
html += `
`
// 第二行: MA + BOLL
if (showMA) {
- html += `
`
+ html += `
`
if (d.ma5 != null) html += `
MA5:${Number(d.ma5).toFixed(2)}`
if (d.ma10 != null) html += `
MA10:${Number(d.ma10).toFixed(2)}`
if (d.ma20 != null) html += `
MA20:${Number(d.ma20).toFixed(2)}`
@@ -949,12 +966,17 @@ export function EChartsCandlestick({
}, [data, stockInfo, showMA, activeIndicators])
getInfoBarHTMLRef.current = getInfoBarHTML
- // data 变化时重置 infoIdx
+ // data/symbol 变化时重置 infoIdx:
+ // symbol(_symbol) 进依赖是必要的——预取切股到同长度邻股时 data.length 不变,
+ // 但悬停上下文来自上一只股票, 必须清掉 hoverActiveRef 以免「至今/周期」残留显示。
+ // (同一股的实时刷新 symbol 不变, 不触发, 悬停位置与「至今」保持实时)
useEffect(() => {
infoIdxRef.current = data.length - 1
compactRef.current = false
userZoomRef.current = null
- }, [data.length])
+ // 新数据无悬停上下文, 隐藏「至今」; 下次鼠标移动时由 updateAxisPointer 重新置位
+ hoverActiveRef.current = false
+ }, [_symbol, data.length])
// ===== 初始化 chart (只在 chartHeight 变化时重建) =====
useEffect(() => {
@@ -965,32 +987,36 @@ export function EChartsCandlestick({
chartRef.current = chart
// 鼠标移动 → 只更新 ref + DOM,不触发 React re-render
- // 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏
+ // 设计原则: 找不到有效数据时保持上次显示,永远不清空信息栏; 鼠标移出时仅隐藏「至今」。
chart.on('updateAxisPointer', (event: any) => {
const axesInfo = event.axesInfo
- if (!axesInfo) return // 鼠标移出图表区域,保持当前显示
- for (const info of Object.values(axesInfo)) {
- const val = (info as any)?.value
- if (val == null) continue
- const d = dataRef.current
- const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val)
- if (idx >= 0 && idx < d.length) {
- if (infoIdxRef.current === idx) return
- infoIdxRef.current = idx
-
- // 直接更新信息栏 DOM (通过 ref 读取最新的生成函数)
- const infoEl = infoBarRef.current
- if (infoEl) {
- const html = getInfoBarHTMLRef.current()
- if (html) infoEl.innerHTML = html // 只在有内容时更新
- }
-
- // 更新子图 graphic
- triggerInfoBarUpdate()
- return
+ const d = dataRef.current
+ // 竖虚线是否正落在某根有效 K 线上 (鼠标在图表数据区内)
+ let foundIdx = -1
+ if (axesInfo) {
+ for (const info of Object.values(axesInfo)) {
+ const val = (info as any)?.value
+ if (val == null) continue
+ const idx = typeof val === 'number' ? val : d.findIndex(x => x.date === val)
+ if (idx >= 0 && idx < d.length) { foundIdx = idx; break }
}
}
- // 没有找到有效数据 — 不做任何操作,保持上次显示
+ const active = foundIdx >= 0
+ const idxChanged = foundIdx >= 0 && infoIdxRef.current !== foundIdx
+ const visChanged = active !== hoverActiveRef.current
+ hoverActiveRef.current = active
+ if (idxChanged) infoIdxRef.current = foundIdx
+ // 竖虚线显隐或悬停 K 线变化 → 重绘一次信息栏 (控制「至今」字段显隐 + 当前 K 线数据)
+ if (visChanged || idxChanged) {
+ const infoEl = infoBarRef.current
+ if (infoEl) {
+ const html = getInfoBarHTMLRef.current()
+ if (html) infoEl.innerHTML = html // 只在有内容时更新
+ }
+ }
+ if (foundIdx < 0) return
+ // 更新子图 graphic (仅悬停 K 线变化时; 纯显隐切换不影响副图)
+ if (idxChanged) triggerInfoBarUpdate()
})
chart.on('click', (params: any) => {
@@ -1159,7 +1185,7 @@ export function EChartsCandlestick({
if (!d) return ''
const floatShares = stockInfo?.float_shares
const turnoverRate = floatShares && d.volume ? (d.volume * 100 / floatShares * 100) : null
- let html = `
`
+ let html = `
`
html += `${d.date}`
html += `开`
html += `${d.open.toFixed(2)}`
@@ -1182,7 +1208,7 @@ export function EChartsCandlestick({
}
html += `
`
if (showMA) {
- html += `
`
+ html += `
`
if (d.ma5 != null) html += `
MA5:${Number(d.ma5).toFixed(2)}`
if (d.ma10 != null) html += `
MA10:${Number(d.ma10).toFixed(2)}`
if (d.ma20 != null) html += `
MA20:${Number(d.ma20).toFixed(2)}`
diff --git a/frontend/src/components/StockDailyKChart.tsx b/frontend/src/components/StockDailyKChart.tsx
index 495ecc4..142a1a5 100644
--- a/frontend/src/components/StockDailyKChart.tsx
+++ b/frontend/src/components/StockDailyKChart.tsx
@@ -1,7 +1,7 @@
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useMemo, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
-import { api, type KlineRow } from '@/lib/api'
-import { QK } from '@/lib/queryKeys'
+import { type KlineRow } from '@/lib/api'
+import { klineDailyQueryOptions } from '@/lib/kline'
import { storage } from '@/lib/storage'
import {
EChartsCandlestick,
@@ -11,13 +11,11 @@ import {
type ChartPriceLine,
type ChartRange,
type OHLC,
- type StockInfo,
type VolumeCompareConfig,
} from '@/components/EChartsCandlestick'
const SUB_INFO_H = 16
const SUB_GAP = 4
-const MAX_DAYS = 2000
const DEFAULT_VOLUME_COMPARE: VolumeCompareConfig = { enabled: true, days: 1 }
function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfig {
@@ -27,13 +25,6 @@ function normalizeVolumeCompare(config: VolumeCompareConfig): VolumeCompareConfi
}
}
-export interface StockDailyKChartResult {
- rows: OHLC[]
- rawRows: KlineRow[]
- stockInfo?: StockInfo
- name?: string
-}
-
interface Props {
symbol: string
height?: number
@@ -51,7 +42,6 @@ interface Props {
linkedPrice?: number | null
onDateClick?: (date: string) => void
onPriceDoubleClick?: (price: number, currentPrice: number) => void
- onDataChange?: (result: StockDailyKChartResult) => void
/** 扩展数据列参数(逗号分隔 config_id.field_name),透传给 klineDaily 接口 */
extColumns?: string
/** 日K自动刷新间隔(ms)。undefined = 不轮询(默认)。个股对话框实时刷新时传入, 盘中今日蜡烛随之更新 */
@@ -113,12 +103,6 @@ export function getDefaultRange(): { start: string; end: string } {
return { start, end }
}
-function rangeDays(range: { start: string; end: string }): number {
- const start = new Date(range.start)
- const end = new Date(range.end)
- return Math.min(Math.ceil((end.getTime() - start.getTime()) / 86400000) + 30, MAX_DAYS)
-}
-
export function StockDailyKChart({
symbol,
height = 520,
@@ -136,7 +120,6 @@ export function StockDailyKChart({
linkedPrice,
onDateClick,
onPriceDoubleClick,
- onDataChange,
extColumns,
refetchIntervalMs,
}: Props) {
@@ -146,16 +129,9 @@ export function StockDailyKChart({
normalizeVolumeCompare(storage.stockVolumeCompare.get(DEFAULT_VOLUME_COMPARE)),
)
const dateRange = externalDateRange ?? getDefaultRange()
- const days = useMemo(() => rangeDays(dateRange), [dateRange])
- // extColumns 纳入 query key:勾选/取消扩展字段时需重新请求(带 ext_columns 参数)
- const kline = useQuery({
- queryKey: QK.kline(symbol, dateRange.start, dateRange.end, extColumns),
- queryFn: () => api.klineDaily(symbol, days, dateRange, extColumns),
- enabled: !!symbol,
- refetchInterval: refetchIntervalMs,
- placeholderData: (prev) => prev,
- })
+ // 查询配置统一来自 klineDailyQueryOptions, 与 StockPanel 信息条/邻近预取共享同一 cache key (只发一次请求)
+ const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol, refetchInterval: refetchIntervalMs })
const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
const stockInfo = kline.data?.stock_info
@@ -185,10 +161,6 @@ export function StockDailyKChart({
if (activeSubDefs.length > 0) subExtraH += activeSubDefs.length * SUB_GAP + 14
const chartHeight = height + subExtraH
- useEffect(() => {
- onDataChange?.({ rows, rawRows: kline.data?.rows ?? [], stockInfo, name: kline.data?.name })
- }, [kline.data?.name, kline.data?.rows, onDataChange, rows, stockInfo])
-
if (!symbol) return null
return (
diff --git a/frontend/src/components/StockInfoBar.tsx b/frontend/src/components/StockInfoBar.tsx
index 1e2d6c8..7f01ebd 100644
--- a/frontend/src/components/StockInfoBar.tsx
+++ b/frontend/src/components/StockInfoBar.tsx
@@ -1,10 +1,11 @@
import { useState, type ReactNode } from 'react'
-import { Settings2, RadioTower, Star } from 'lucide-react'
+import { Settings2, RadioTower, Star, ExternalLink } from 'lucide-react'
import type { KlineRow, FinancialMetricRecord } from '@/lib/api'
import { fmtPrice, fmtBigNum, fmtVolume } from '@/lib/format'
import { ListColumnCustomizer } from '@/components/ListColumnCustomizer'
import { WatchlistAddMenu } from '@/components/WatchlistAddMenu'
import { INFO_GROUPS, type ColumnConfig } from '@/lib/stock-info-fields'
+import { buildStockExternalUrl, loadStockExternalTemplate } from '@/lib/stock-external-link'
const BULL = '#C74040'
const BEAR = '#2D9B65'
@@ -122,7 +123,32 @@ export function StockInfoBar({
})
}
- if (rows.length === 0) return null
+ // 字段分组: 加载态预留高度与完整态渲染共用同一规则
+ const visibleFields = fields.filter(f => f.visible)
+ const inlineFields = visibleFields.filter(f => !f.standalone)
+ const standaloneFields = visibleFields.filter(f => f.standalone)
+
+ // 无数据时保持信息条挂载 (切股/首次加载): 只留 symbol+名称+小 spinner 作为加载态,
+ // 不渲染假占位值; 数据到位后价格/市值等原位填充, 避免整行消失造成布局跳动。
+ // 同时按字段配置预留与完整态相同的行数, 切股瞬间弹窗整体高度不塌陷 (不抖动)。
+ if (rows.length === 0) {
+ const reserveLines = (inlineFields.length > 0 ? 1 : 0) + standaloneFields.length
+ return (
+
+ {/* 首行 min-h-7 对齐完整态的 text-lg 价格行高, 加载中不整体变矮 */}
+
+ {symbol}
+ {name && {name}}
+
+
+
+
+ {Array.from({ length: reserveLines }).map((_, i) => (
+
+ ))}
+
+ )
+ }
const latest = rows[rows.length - 1]
const prev = rows.length >= 2 ? rows[rows.length - 2] : null
@@ -183,11 +209,6 @@ export function StockInfoBar({
}
}
- const visibleFields = fields.filter(f => f.visible)
- // 按是否单独显示分组:普通列共一行,standalone 列各占一行
- const inlineFields = visibleFields.filter(f => !f.standalone)
- const standaloneFields = visibleFields.filter(f => f.standalone)
-
// 渲染单个字段(builtin / ext 通用)
const renderField = (f: ColumnConfig): ReactNode => {
if (f.source.type === 'ext') {
@@ -215,6 +236,8 @@ export function StockInfoBar({
)
}
+ const extUrl = buildStockExternalUrl(loadStockExternalTemplate(), symbol)
+
return (
{/* Row 1: code, name, price, change, change% */}
@@ -230,8 +253,19 @@ export function StockInfoBar({
{isUp ? '+' : ''}{fmtPrice(chgPct)}%
- {/* 右侧操作按钮:加自选 + 加监控 + 信息条配置 */}
+ {/* 右侧操作按钮:外链 + 加自选 + 加监控 + 信息条配置 */}
+ {extUrl && (
+
+
+
+ )}
{inWatchlist && onRemoveFromWatchlist ? (