mirror of
https://github.com/shy3130/tick-stock-panel.git
synced 2026-11-02 10:26:13 +00:00
Merge pull request #340 from yushenglin01/fix/intraday-price-summary
fix(intraday): 修复分时顶部价格与日K展示混淆 (#282)
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { EChartsIntraday } from './EChartsIntraday'
|
||||
|
||||
const chart = vi.hoisted(() => ({
|
||||
handlers: {} as Record<string, (event?: any) => void>,
|
||||
on: vi.fn(), off: vi.fn(), setOption: vi.fn(), clear: vi.fn(), resize: vi.fn(), dispose: vi.fn(),
|
||||
getZr: () => ({ on: vi.fn(), off: vi.fn() }),
|
||||
}))
|
||||
vi.mock('echarts', () => ({ init: () => chart }))
|
||||
vi.mock('@/lib/theme', () => ({ useChartTheme: () => ({}) }))
|
||||
const rows = [
|
||||
{ datetime: '2026-09-09 09:30:00', open: 119.77, high: 119.77, low: 119, close: 119.1, volume: 100, amount: 1191000 },
|
||||
{ datetime: '2026-09-09 11:20:00', open: 118.25, high: 118.28, low: 118.24, close: 118.25, volume: 55, amount: 650375 },
|
||||
]
|
||||
const daily = { date: '2026-09-09', open: 119.77, high: 119.77, low: 118.16, close: 118.25 }
|
||||
let cleanup = async () => {}
|
||||
afterEach(async () => { await cleanup(); vi.unstubAllGlobals() })
|
||||
|
||||
it('shows daily OHLC by default, labels hovered minute, restores on exit and date switch', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
|
||||
chart.on.mockImplementation((name, callback) => { chart.handlers[name] = callback })
|
||||
const host = document.createElement('div')
|
||||
const root = createRoot(host)
|
||||
cleanup = async () => { await act(async () => root.unmount()) }
|
||||
const render = async (date = daily.date) => {
|
||||
await act(async () => root.render(<EChartsIntraday data={rows.map(row => ({ ...row, datetime: row.datetime.replace(daily.date, date) }))}
|
||||
date={date} dailySummary={{ ...daily, date }} />))
|
||||
}
|
||||
await render()
|
||||
expect(host.textContent).toContain('日K')
|
||||
expect(host.textContent).toContain('118.16')
|
||||
await act(async () => chart.handlers.updateAxisPointer({ axesInfo: [{ axisDim: 'x', value: 110 }] }))
|
||||
expect(host.textContent).toContain('11:20')
|
||||
expect(host.textContent).toContain('118.28')
|
||||
await act(async () => chart.handlers.globalout())
|
||||
expect(host.textContent).toContain('118.16')
|
||||
expect(host.textContent).not.toContain('11:20')
|
||||
await act(async () => chart.handlers.updateAxisPointer({ axesInfo: [{ axisDim: 'x', value: 110 }] }))
|
||||
await render('2026-09-10')
|
||||
expect(host.textContent).toContain('118.16')
|
||||
expect(host.textContent).not.toContain('11:20')
|
||||
})
|
||||
|
||||
it('aggregates available minutes without inventing a missing opening price', async () => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
|
||||
vi.stubGlobal('ResizeObserver', class { observe() {} disconnect() {} })
|
||||
const host = document.createElement('div')
|
||||
const root = createRoot(host)
|
||||
cleanup = async () => { await act(async () => root.unmount()) }
|
||||
await act(async () => root.render(<EChartsIntraday data={rows.map(row => ({ ...row, open: null }))} date={daily.date} />))
|
||||
expect(host.textContent).toContain('分时汇总')
|
||||
expect(host.textContent).toContain('119.77')
|
||||
expect(host.textContent).toContain('—')
|
||||
expect(host.textContent).toContain('155')
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as echarts from 'echarts'
|
||||
import type { ECharts, EChartsOption } from 'echarts'
|
||||
import type { MinuteKlineRow, PriceLimitInfo } from '@/lib/api'
|
||||
import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES } from '@/lib/intraday-chart'
|
||||
import { computeIntradayAverage, formatMinuteTime, FULL_DAY_TIMES, summarizeMinutes, type DailySummary } from '@/lib/intraday-chart'
|
||||
import { useChartTheme, type ChartTheme } from '@/lib/theme'
|
||||
|
||||
type YMode = 'adaptive' | 'limit'
|
||||
@@ -21,6 +21,7 @@ interface Props {
|
||||
height?: number
|
||||
prevClose?: number
|
||||
date?: string
|
||||
dailySummary?: DailySummary
|
||||
priceLimit?: PriceLimitInfo
|
||||
onPriceHover?: (price: number | null) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
@@ -402,6 +403,7 @@ export function EChartsIntraday({
|
||||
height = 320,
|
||||
prevClose,
|
||||
date,
|
||||
dailySummary,
|
||||
priceLimit,
|
||||
onPriceHover,
|
||||
onPriceDoubleClick,
|
||||
@@ -426,10 +428,11 @@ export function EChartsIntraday({
|
||||
// 全日索引 → 数据数组索引 的映射 (ref 避免重建 chart)
|
||||
const fullDayToDataIdx = useRef<Map<number, number>>(new Map())
|
||||
|
||||
const [infoIdx, setInfoIdx] = useState(data.length - 1)
|
||||
const [infoIdx, setInfoIdx] = useState(-1)
|
||||
const [yMode, setYMode] = useState<YMode>('adaptive')
|
||||
const ct = useChartTheme()
|
||||
const avgPrices = useMemo(() => computeIntradayAverage(data), [data])
|
||||
const summary = useMemo(() => summarizeMinutes(data), [data])
|
||||
|
||||
// 分时线颜色:基于最新价 vs 昨收
|
||||
const lastClose = data.length > 0 ? data[data.length - 1].close : null
|
||||
@@ -439,8 +442,8 @@ export function EChartsIntraday({
|
||||
const areaFill = lineIsFlat ? 'rgba(180,180,190,0.40)' : lineIsUp ? 'rgba(199,64,64,0.40)' : 'rgba(34,197,94,0.40)'
|
||||
|
||||
useEffect(() => {
|
||||
setInfoIdx(data.length - 1)
|
||||
}, [data.length])
|
||||
setInfoIdx(-1)
|
||||
}, [date])
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
@@ -470,6 +473,7 @@ export function EChartsIntraday({
|
||||
const axesInfo = event.axesInfo
|
||||
if (!axesInfo) return
|
||||
for (const info of Object.values(axesInfo)) {
|
||||
if ((info as any)?.axisDim !== 'x') continue
|
||||
const val = (info as any)?.value
|
||||
if (val == null) continue
|
||||
const fullDayIdx = typeof val === 'number' ? val : -1
|
||||
@@ -479,6 +483,8 @@ export function EChartsIntraday({
|
||||
const d = dataRef.current
|
||||
if (dataIdx >= 0 && dataIdx < d.length) {
|
||||
onPriceHoverRef.current?.(d[dataIdx].close)
|
||||
} else {
|
||||
onPriceHoverRef.current?.(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -486,6 +492,7 @@ export function EChartsIntraday({
|
||||
})
|
||||
|
||||
chart.on('globalout', () => {
|
||||
setInfoIdx(-1)
|
||||
onPriceHoverRef.current?.(null)
|
||||
})
|
||||
|
||||
@@ -518,6 +525,7 @@ export function EChartsIntraday({
|
||||
|
||||
chart.setOption(buildOption(data, prevClose, avgPrices, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines), true)
|
||||
} else {
|
||||
fullDayToDataIdx.current = new Map()
|
||||
chart.clear()
|
||||
}
|
||||
}, [data, prevClose, height, lineColor, areaFill, yMode, ct, priceLimit, showLimitLines, showAvgLine, priceLines])
|
||||
@@ -538,8 +546,11 @@ export function EChartsIntraday({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const d = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
|
||||
const avg = d != null ? avgPrices[infoIdx] : null
|
||||
const hovered = infoIdx >= 0 && infoIdx < data.length ? data[infoIdx] : null
|
||||
const d = hovered ?? summary
|
||||
const daily = dailySummary?.date === date ? dailySummary : undefined
|
||||
const ohlc = hovered ?? daily ?? summary
|
||||
const avg = d != null ? avgPrices[hovered ? infoIdx : data.length - 1] : null
|
||||
const chg = d && prevClose != null ? d.close - prevClose : null
|
||||
const isUp = chg != null ? chg > 0 : true
|
||||
const isFlat = chg != null ? chg === 0 : false
|
||||
@@ -575,24 +586,25 @@ export function EChartsIntraday({
|
||||
</div>}
|
||||
<div style={{ backgroundColor: ct.infoBarBg }}>
|
||||
{/* 第一行: 日期 + OHLC */}
|
||||
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ height: 20 }}>
|
||||
<div className="flex items-center gap-x-2 px-2 font-mono text-[11px] select-none flex-wrap" style={{ minHeight: 20 }}>
|
||||
{!d && <span className="text-muted">—</span>}
|
||||
{d && (
|
||||
{ohlc && (
|
||||
<>
|
||||
{date && <span className="text-muted">{date}</span>}
|
||||
<span className="text-muted">{hovered ? `${formatMinuteTime(hovered.datetime)} 分钟` : daily ? '日K(前复权)' : '分时汇总'}</span>
|
||||
<span className="text-muted">开</span>
|
||||
<span style={{ color: priceClr }}>{d.open != null ? d.open.toFixed(2) : '—'}</span>
|
||||
<span style={{ color: priceClr }}>{ohlc.open != null ? ohlc.open.toFixed(2) : '—'}</span>
|
||||
<span className="text-muted">高</span>
|
||||
<span style={{ color: priceClr }}>{d.high.toFixed(2)}</span>
|
||||
<span style={{ color: priceClr }}>{ohlc.high.toFixed(2)}</span>
|
||||
<span className="text-muted">低</span>
|
||||
<span style={{ color: priceClr }}>{d.low.toFixed(2)}</span>
|
||||
<span style={{ color: priceClr }}>{ohlc.low.toFixed(2)}</span>
|
||||
<span className="text-muted">收</span>
|
||||
<span style={{ color: priceClr }} className="font-semibold">{d.close.toFixed(2)}</span>
|
||||
<span style={{ color: priceClr }} className="font-semibold">{ohlc.close.toFixed(2)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* 第二行: 价格+均价+量+额 */}
|
||||
<div className="flex items-center gap-x-4 px-2 font-mono text-[11px] select-none" style={{ height: 20 }}>
|
||||
<div className="flex flex-wrap items-center gap-x-2 px-2 font-mono text-[11px] select-none" style={{ minHeight: 20 }}>
|
||||
{d && (
|
||||
<>
|
||||
<span className="flex items-center gap-x-1">
|
||||
@@ -603,9 +615,9 @@ export function EChartsIntraday({
|
||||
<span style={{ display: 'inline-block', width: 14, height: 2, background: THEME.avgLine }} />
|
||||
<span style={{ color: THEME.avgLine }}>{avg != null ? avg.toFixed(2) : '—'}</span>
|
||||
</span>}
|
||||
<span className="text-muted">量</span>
|
||||
<span className="text-muted">{hovered ? '量' : '累计量'}</span>
|
||||
<span className="text-secondary">{d.volume.toFixed(0)}</span>
|
||||
<span className="text-muted">额</span>
|
||||
<span className="text-muted">{hovered ? '额' : '累计额'}</span>
|
||||
<span className="text-secondary">{fmtAmt(d.amount)}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { api, type MinuteKlineRow } from '@/lib/api'
|
||||
import { QK } from '@/lib/queryKeys'
|
||||
import { klineMinuteQueryOptions, minuteRefetchInterval } from '@/lib/kline'
|
||||
import { EChartsIntraday } from '@/components/EChartsIntraday'
|
||||
import type { DailySummary } from '@/lib/intraday-chart'
|
||||
|
||||
interface Props {
|
||||
symbol: string
|
||||
date: string | null
|
||||
height?: number
|
||||
prevClose?: number
|
||||
dailySummary?: DailySummary
|
||||
className?: string
|
||||
onPriceHover?: (price: number | null) => void
|
||||
onPriceDoubleClick?: (price: number, currentPrice: number) => void
|
||||
@@ -25,6 +27,7 @@ export function StockIntradayChart({
|
||||
date,
|
||||
height = 520,
|
||||
prevClose,
|
||||
dailySummary,
|
||||
className,
|
||||
onPriceHover,
|
||||
onPriceDoubleClick,
|
||||
@@ -125,7 +128,8 @@ export function StockIntradayChart({
|
||||
<EChartsIntraday
|
||||
data={minuteRows}
|
||||
height={height}
|
||||
prevClose={prevClose}
|
||||
prevClose={minute.data?.prev_close ?? prevClose}
|
||||
dailySummary={dailySummary}
|
||||
date={date}
|
||||
priceLimit={minute.data?.price_limit ?? undefined}
|
||||
onPriceHover={onPriceHover}
|
||||
|
||||
@@ -172,6 +172,7 @@ export function StockPanel({
|
||||
}, [showIntraday, selectedDate, rows])
|
||||
|
||||
const selectedIdx = selectedDate ? rows.findIndex(r => r.date === selectedDate) : -1
|
||||
const selectedRow = selectedIdx >= 0 ? rows[selectedIdx] : undefined
|
||||
const prevClose = selectedIdx > 0
|
||||
? rows[selectedIdx - 1].close
|
||||
: rows.length >= 2
|
||||
@@ -233,6 +234,7 @@ export function StockPanel({
|
||||
date={selectedDate}
|
||||
height={height}
|
||||
prevClose={prevClose}
|
||||
dailySummary={selectedRow}
|
||||
onPriceHover={setLinkedPrice}
|
||||
onPriceDoubleClick={onPriceDoubleClick}
|
||||
currentPrice={rows[rows.length - 1]?.close}
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
import type { MinuteKlineRow } from '@/lib/api'
|
||||
|
||||
export type DailySummary = { date: string; open: number | null; high: number; low: number; close: number }
|
||||
|
||||
/** 仅汇总实际收到的分钟,不将缺失开盘价或成交额伪装为真实值。 */
|
||||
export function summarizeMinutes(data: MinuteKlineRow[]): MinuteKlineRow | null {
|
||||
if (!data.length) return null
|
||||
return {
|
||||
datetime: data[0].datetime,
|
||||
open: data[0].open,
|
||||
high: Math.max(...data.map(row => row.high)),
|
||||
low: Math.min(...data.map(row => row.low)),
|
||||
close: data[data.length - 1].close,
|
||||
volume: data.reduce((total, row) => total + row.volume, 0),
|
||||
amount: data.every(row => row.amount != null && Number.isFinite(row.amount))
|
||||
? data.reduce((total, row) => total + row.amount!, 0) : null,
|
||||
}
|
||||
}
|
||||
|
||||
/** 从 datetime 串取 HH:MM。契约: 分钟K datetime 已在后端入口统一为北京墙钟, 前端不做时区换算。 */
|
||||
export function formatMinuteTime(datetime: string): string {
|
||||
const match = datetime.match(/(\d{2}):(\d{2})/)
|
||||
|
||||
Reference in New Issue
Block a user