Files
auv/src/components/kline-card.tsx
T

253 lines
9.2 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.
/**
* 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<ChartPeriod>("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<KBarInput[]>([]);
const [minuteData, setMinuteData] = useState<KBarInput[]>([]);
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<ChartPeriod, "d">) => {
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 (
<Card className="shadow-lg">
<CardHeader className="pb-2 md:pb-4 px-3 md:px-6 pt-4 md:pt-6">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-base md:text-lg">
K线图
<span className="text-xs md:text-sm font-normal text-muted-foreground ml-2">
{currentLoading ? "(加载中...)" : (
chartPeriod === "d"
? `(${displayData.length}个交易日)`
: `(${displayData.length}根${PERIOD_LABEL[chartPeriod]}K线)`
)}
</span>
</CardTitle>
<div className="flex items-center gap-1.5 flex-wrap">
{/* 显示方式切换:折线 / 蜡烛 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{(["line", "candle"] as const).map((m) => (
<button
key={m}
onClick={() => setChartMode(m)}
className={`px-2.5 py-1 transition-colors ${
chartMode === m ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{m === "line" ? "折线" : "蜡烛"}
</button>
))}
</div>
{/* 指标开关:MA / MACD / RSI */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{([
{ key: "ma", label: "MA" },
{ key: "macd", label: "MACD" },
{ key: "rsi", label: "RSI" },
] as const).map((it) => (
<button
key={it.key}
onClick={() => toggleIndicator(it.key)}
className={`px-2.5 py-1 transition-colors ${
indicators[it.key] ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{it.label}
</button>
))}
</div>
{/* K线周期:日 / 60分 / 30分 / 15分 / 5分 / 1分 */}
<div className="flex gap-0.5 text-xs border rounded-md overflow-hidden">
{([
{ 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) => (
<button
key={p.key}
onClick={() => setChartPeriod(p.key)}
className={`px-2 py-1 transition-colors ${
chartPeriod === p.key ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{p.label}
</button>
))}
</div>
<span className="text-xs text-muted-foreground hidden sm:inline">
双指缩放 · 拖动查看
</span>
</div>
</div>
</CardHeader>
<CardContent className="px-2 md:px-6 pb-2 md:pb-6">
{currentLoading ? (
<div className="h-[390px] w-full flex items-center justify-center">
<div className="flex items-center text-muted-foreground text-sm">
<div className="animate-pulse mr-2 h-2 w-2 rounded-full bg-primary"></div>
K线数据加载中...
</div>
</div>
) : (
<KLineChart
data={chartData}
mode={chartMode}
hasAddedDate={ready}
indicators={indicators}
defaultVisibleDays={chartPeriod === "d" ? 90 : undefined}
/>
)}
</CardContent>
</Card>
);
}
export default KLineCard;