Files
tick-stock-panel/frontend/src/components/StockIntradayChart.tsx
T
shy3130 af97df1349 fix(frontend): 分时轮询停表 — 历史日/收盘定版后不再无效轮询
自选个股详情弹窗打开即按 6s 轮询分时接口 (StockPreviewDialog), 但两类
场景下数据不可变, 轮询纯属浪费: 周末/翻看历史日期 (后端回退出的历史日
本地分钟K不变), 以及当日收盘定版后。

新增共享停表工厂 minuteRefetchInterval (kline.ts 单一权威):
- source=none / 响应日期 < 北京今天 → 停
- 当日: 收盘根 (≥15:00 的K) 已出现 且 已过 15:30 → 停。
  完整性只认收盘根不按根数 —— 实测全天 241 根 (含 09:30 竞价根与
  15:00 收盘根), 按根数阈值会在第 240 根 (14:59) 到位时误停, 错过
  收盘根; 15:00 后盘后固定价成交可持续到 15:30, 其间量/额仍可能变,
  与后端 quote 定版窗口终点 (15:30) 同口径
- 其余 (当日盘中) 照常轮询, 实时性不受影响

StockMultiDayIntradayChart 的最新日查询此前无条件轮询 (无 source=none
停表), 一并接入。判定扫全行取时间标签, 不依赖后端排序契约。

验证: pnpm build 通过。
2026-09-06 22:35:30 +08:00

140 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
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, minuteRefetchInterval } from '@/lib/kline'
import { EChartsIntraday } from '@/components/EChartsIntraday'
interface Props {
symbol: string
date: string | null
height?: number
prevClose?: number
className?: string
onPriceHover?: (price: number | null) => void
onPriceDoubleClick?: (price: number, currentPrice: number) => void
currentPrice?: number
priceLines?: { value: number; label?: string; color?: string }[]
/** 自动刷新间隔(ms)。undefined/0 = 不轮询(默认)。个股对话框盘中实时刷新时传入。 */
refetchIntervalMs?: number
}
export function StockIntradayChart({
symbol,
date,
height = 520,
prevClose,
className,
onPriceHover,
onPriceDoubleClick,
currentPrice,
priceLines,
refetchIntervalMs,
}: Props) {
const qc = useQueryClient()
const [minuteDismissed, setMinuteDismissed] = useState(false)
const minute = useQuery({
// 轮询上下文 (个股详情) 传 live: 当日盘中后端直接实时拉取最新K,
// 避免读到分钟增量落盘的上一轮本地分区; 历史日期后端自行忽略 live。
...klineMinuteQueryOptions(symbol, date ?? undefined, refetchIntervalMs != null),
enabled: !!symbol && !!date,
refetchInterval: minuteRefetchInterval(refetchIntervalMs),
})
const fetchMinute = useMutation({
mutationFn: () => api.syncMinuteSingle(symbol),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['kline-minute', symbol] })
qc.invalidateQueries({ queryKey: QK.klineMinute(symbol, date ?? '') })
setMinuteDismissed(false)
},
})
const minuteRows: MinuteKlineRow[] = useMemo(() => minute.data?.rows ?? [], [minute.data?.rows])
// source=none 表示本地无数据且 TickFlow 也拉不到 (停牌/复牌延迟/非交易日)
// 此时不弹"是否获取"询问窗, 只做静态提示, 避免误导用户去拉明知拉不到的数据
const sourceIsNone = minute.data?.source === 'none'
// 指数分钟K无本地存储且不支持落库获取 (后端 sync_minute_single 显式拒绝), 不显示获取按钮
const isIndex = minute.data?.asset_type === 'index'
useEffect(() => {
setMinuteDismissed(false)
onPriceHover?.(null)
}, [date, onPriceHover])
if (!symbol || !date) return null
return (
<div className={className} style={{ height, flexShrink: 0 }}>
{minute.isLoading && <div className="text-xs text-muted py-2">分时加载中…</div>}
{!minute.isLoading && minuteRows.length === 0 && (
<>
{fetchMinute.isPending ? (
<div className="flex items-center justify-center h-full gap-2 text-xs text-accent">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
<span>正在获取分钟K数据</span>
</div>
) : isIndex ? (
// 指数: 分钟K仅支持实时读取, 无落库获取入口
<div className="flex items-center justify-center h-full text-xs text-muted">指数暂无分钟数据</div>
) : sourceIsNone ? (
// 数据源确认无此日分钟数据 (停牌/复牌延迟等): 静态提示 + 保留重试
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-xs text-muted">该日暂无分钟数据(数据源未提供)</div>
<button
onClick={() => fetchMinute.mutate()}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs font-medium hover:bg-elevated/80 transition-colors duration-150"
>
重新获取
</button>
</div>
) : minuteDismissed ? (
<div className="flex flex-col items-center justify-center h-full gap-3">
<div className="text-xs text-muted">暂无分钟数据</div>
<button
onClick={() => setMinuteDismissed(false)}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors duration-150"
>
获取分钟K
</button>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full gap-4">
<div className="text-sm text-foreground">是否立即获取最近5日分钟K</div>
<div className="flex items-center gap-3">
<button
onClick={() => fetchMinute.mutate()}
className="px-4 py-1.5 rounded-btn bg-accent/90 text-base text-xs font-medium hover:bg-accent transition-colors duration-150"
>
确定
</button>
<button
onClick={() => setMinuteDismissed(true)}
className="px-4 py-1.5 rounded-btn bg-elevated text-secondary text-xs hover:bg-elevated/80 transition-colors duration-150"
>
取消
</button>
</div>
</div>
)}
</>
)}
{minuteRows.length > 0 && (
<EChartsIntraday
data={minuteRows}
height={height}
prevClose={prevClose}
date={date}
priceLimit={minute.data?.price_limit ?? undefined}
onPriceHover={onPriceHover}
onPriceDoubleClick={onPriceDoubleClick}
currentPrice={currentPrice}
priceLines={priceLines}
/>
)}
</div>
)
}