253 lines
9.2 KiB
TypeScript
253 lines
9.2 KiB
TypeScript
/**
|
||
* 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;
|