mirror of
https://ghfast.top/https://github.com/aeroxw/tick-stock-panel.git
synced 2026-09-12 15:34:16 +08:00
自选个股详情弹窗打开即按 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 通过。
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
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>
|
||
)
|
||
}
|