/** * K 线图卡片:独立组件,自带周期(日/60分/...)、显示方式(折线/蜡烛)、 * 指标开关(MA/MACD/RSI)状态与数据加载逻辑,与页面其他模块解耦。 * 行情就绪(stockInfo 就绪)后自动拉取;切换周期只重拉 K 线,不影响页面其它数据。 */ import { useEffect, useMemo, useState } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import KLineChart from "@/components/kline-chart"; import { fetchStockHistoryV2, } from "@/lib/fuyao-api"; import { fetchStockHistoryMinute } from "@/lib/stock-api"; type ChartPeriod = "d" | "m60" | "m30" | "m15" | "m5" | "m1"; // 分钟周期标签映射 const PERIOD_LABEL: Record<"m1" | "m5" | "m15" | "m30" | "m60", string> = { m1: "1分", m5: "5分", m15: "15分", m30: "30分", m60: "60分", }; interface KBarInput { date: string; dateMs: number; // Unix 秒(lightweight-charts UTCTimestamp) open: number; close: number; high: number; low: number; volume: number; isAddedDate: boolean; } interface Props { code: string; addedAt: string; // 自选日期 ISO,用于标记K线 /** 行情是否就绪:就绪后才开始拉 K 线 */ ready: boolean; } export function KLineCard({ code, addedAt, ready }: Props) { const [chartPeriod, setChartPeriod] = useState("d"); const [chartMode, setChartMode] = useState<"line" | "candle">("candle"); const [indicators, setIndicators] = useState({ ma: true, macd: false, rsi: false }); const toggleIndicator = (key: keyof typeof indicators) => setIndicators((prev) => ({ ...prev, [key]: !prev[key] })); const [dailyData, setDailyData] = useState([]); const [minuteData, setMinuteData] = useState([]); const [chartLoading, setChartLoading] = useState(false); const [minuteLoading, setMinuteLoading] = useState(false); const toggleMs = useMemo(() => new Date(addedAt).getTime(), [addedAt]); // KLineData → KBarInput(含 isAddedDate 标记,非交易日标最近一根) // 时间统一按 UTC 解析(wall-clock 视作 UTC),lightweight-charts 以 UTC 渲染, // 这样日线标签不偏移、分钟线显示的正是北京时间。 const toBars = ( klines: Array<{ date: string; open: number; close: number; high: number; low: number; volume: number }>, ): KBarInput[] => { const bars: KBarInput[] = klines.map((k) => { const iso = k.date.includes(" ") ? k.date.replace(" ", "T") + ":00Z" // "2026-05-08 13:30" → 2026-05-08T13:30:00Z : k.date + "T00:00:00Z"; // "2026-05-08" → 2026-05-08T00:00:00Z const ms = new Date(iso).getTime(); const dateMs = Number.isFinite(ms) ? Math.floor(ms / 1000) : 0; return { date: k.date, dateMs, open: k.open, close: k.close, high: k.high, low: k.low, volume: k.volume, isAddedDate: new Date(ms).toDateString() === new Date(toggleMs).toDateString(), }; }); if (!bars.some((b) => b.isAddedDate) && bars.length > 0) { let closestIdx = 0; let closestDiff = Infinity; bars.forEach((b, i) => { const diff = Math.abs(b.dateMs * 1000 - toggleMs); if (diff < closestDiff) { closestDiff = diff; closestIdx = i; } }); bars[closestIdx] = { ...bars[closestIdx], isAddedDate: true }; } return bars; }; // 拉日K const loadDaily = async () => { setChartLoading(true); try { const data = await fetchStockHistoryV2(code, 365); if (data && data.length > 0) setDailyData(toBars(data)); } catch (err) { console.error("[kline-card] 拉取日K失败:", err); } finally { setChartLoading(false); } }; // 拉分钟/小时K const loadMinute = async (period: Exclude) => { setMinuteLoading(true); try { const data = await fetchStockHistoryMinute(code, period, 320); if (data && data.length > 0) setMinuteData(toBars(data)); else setMinuteData([]); } catch (err) { console.error("[kline-card] 拉取分钟K失败:", err); setMinuteData([]); } finally { setMinuteLoading(false); } }; // 行情就绪后拉当前周期的K线;切换周期只重拉K线,不动页面其它数据 useEffect(() => { if (!ready) return; if (chartPeriod === "d") { if (dailyData.length > 0) return; // 已加载过,复用 loadDaily(); } else { setMinuteData([]); // 清空上一个周期旧数据,避免串显 loadMinute(chartPeriod); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [chartPeriod, ready, code]); // 展示数据:日线直接用,分钟线用 minuteData const displayData = chartPeriod === "d" ? dailyData : minuteData; const currentLoading = chartPeriod === "d" ? chartLoading : minuteLoading; // 传给图表的数据引用需稳定(仅随 displayData 变化), // 否则切换指标/显示方式时 KLineChart 会把新数组当成新数据集重置视口 const chartData = useMemo( () => displayData.map((d) => ({ time: d.dateMs, open: d.open, close: d.close, high: d.high, low: d.low, volume: d.volume, isAddedDate: d.isAddedDate, })), [displayData], ); return (
K线图 {currentLoading ? "(加载中...)" : ( chartPeriod === "d" ? `(${displayData.length}个交易日)` : `(${displayData.length}根${PERIOD_LABEL[chartPeriod]}K线)` )}
{/* 显示方式切换:折线 / 蜡烛 */}
{(["line", "candle"] as const).map((m) => ( ))}
{/* 指标开关:MA / MACD / RSI */}
{([ { key: "ma", label: "MA" }, { key: "macd", label: "MACD" }, { key: "rsi", label: "RSI" }, ] as const).map((it) => ( ))}
{/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */}
{([ { key: "d" as const, label: "日" }, { key: "m60" as const, label: "60分" }, { key: "m30" as const, label: "30分" }, { key: "m15" as const, label: "15分" }, { key: "m5" as const, label: "5分" }, { key: "m1" as const, label: "1分" }, ] as const).map((p) => ( ))}
双指缩放 · 拖动查看
{currentLoading ? (
K线数据加载中...
) : ( )}
); } export default KLineCard;