Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions frontend/src/components/DimensionMembersDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'
import { Building2, ChevronRight, 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 MarketSnapshotRow } from '@/lib/api'
import { QK } from '@/lib/queryKeys'
import { fmtBigNum, fmtPct, fmtPrice, priceColorClass } from '@/lib/format'
Expand All @@ -29,7 +30,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 {
Expand Down Expand Up @@ -259,7 +260,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)` }}
Expand Down
82 changes: 54 additions & 28 deletions frontend/src/components/EChartsCandlestick.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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>`
Expand All @@ -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>`
Expand All @@ -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(() => {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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>`
Expand All @@ -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>`
Expand Down
37 changes: 5 additions & 32 deletions frontend/src/components/StockDailyKChart.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 {
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -111,12 +101,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,
Expand All @@ -134,7 +118,6 @@ export function StockDailyKChart({
linkedPrice,
onDateClick,
onPriceDoubleClick,
onDataChange,
extColumns,
}: Props) {
const [activeIndicators, setActiveIndicators] = useState<string[]>(['vol'])
Expand All @@ -143,15 +126,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,
placeholderData: (prev) => prev,
})
// 查询配置统一来自 klineDailyQueryOptions, 与 StockPanel 信息条/邻近预取共享同一 cache key (只发一次请求)
const kline = useQuery({ ...klineDailyQueryOptions(symbol, dateRange, extColumns), enabled: !!symbol })

const rows = useMemo(() => toOHLC(kline.data?.rows ?? []), [kline.data?.rows])
const stockInfo = kline.data?.stock_info
Expand Down Expand Up @@ -181,10 +158,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 (
Expand Down
50 changes: 42 additions & 8 deletions frontend/src/components/StockInfoBar.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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% */}
Expand All @@ -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"
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/StockIntradayChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -35,8 +36,7 @@ export function StockIntradayChart({
const [minuteDismissed, setMinuteDismissed] = useState(false)

const minute = useQuery({
queryKey: QK.klineMinute(symbol, date ?? ''),
queryFn: () => api.klineMinute(symbol, date ?? undefined),
...klineMinuteQueryOptions(symbol, date ?? undefined),
enabled: !!symbol && !!date,
refetchInterval: refetchIntervalMs,
})
Expand Down
Loading