feat: rate and price kline have independent interval selectors

This commit is contained in:
root 2026-02-27 08:10:48 +00:00
parent 32e9dd3531
commit eebbfd456c

View File

@ -1,46 +1,35 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { createChart, ColorType, CandlestickSeries, LineSeries } from "lightweight-charts";
import { createChart, ColorType, CandlestickSeries } from "lightweight-charts";
const INTERVALS = [
{ label: "1分钟", value: "1m" },
{ label: "5分钟", value: "5m" },
{ label: "30分钟", value: "30m" },
{ label: "1小时", value: "1h" },
{ label: "4小时", value: "4h" },
{ label: "8小时", value: "8h" },
{ label: "日线", value: "1d" },
{ label: "周线", value: "1w" },
{ label: "月线", value: "1M" },
{ label: "1m", value: "1m" },
{ label: "5m", value: "5m" },
{ label: "30m", value: "30m" },
{ label: "1h", value: "1h" },
{ label: "4h", value: "4h" },
{ label: "8h", value: "8h" },
{ label: "日", value: "1d" },
{ label: "周", value: "1w" },
{ label: "月", value: "1M" },
];
interface KBar {
time: number;
open: number;
high: number;
low: number;
close: number;
price_open: number;
price_high: number;
price_low: number;
price_close: number;
open: number; high: number; low: number; close: number;
price_open: number; price_high: number; price_low: number; price_close: number;
}
function chartOptions(height: number) {
function baseChartOptions(height: number) {
return {
layout: {
background: { type: ColorType.Solid, color: "#ffffff" },
textColor: "#64748b",
fontSize: 11,
},
grid: {
vertLines: { color: "#f1f5f9" },
horzLines: { color: "#f1f5f9" },
textColor: "#64748b", fontSize: 11,
},
grid: { vertLines: { color: "#f1f5f9" }, horzLines: { color: "#f1f5f9" } },
localization: {
timeFormatter: (ts: number) => {
// UTC+8 北京时间
const d = new Date((ts + 8 * 3600) * 1000);
const Y = d.getUTCFullYear();
const M = String(d.getUTCMonth() + 1).padStart(2, "0");
@ -51,14 +40,10 @@ function chartOptions(height: number) {
},
},
timeScale: {
borderColor: "#e2e8f0",
timeVisible: true,
secondsVisible: false,
borderColor: "#e2e8f0", timeVisible: true, secondsVisible: false,
tickMarkFormatter: (ts: number) => {
const d = new Date((ts + 8 * 3600) * 1000);
const h = String(d.getUTCHours()).padStart(2, "0");
const m = String(d.getUTCMinutes()).padStart(2, "0");
return `${h}:${m}`;
return `${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}`;
},
},
rightPriceScale: { borderColor: "#e2e8f0" },
@ -66,145 +51,119 @@ function chartOptions(height: number) {
};
}
export default function KlinePage() {
const rateRef = useRef<HTMLDivElement>(null);
const priceRef = useRef<HTMLDivElement>(null);
const [symbol, setSymbol] = useState<"BTC" | "ETH">("BTC");
const [interval, setInterval] = useState("1h");
const [count, setCount] = useState(0);
const [loading, setLoading] = useState(true);
const rateChartRef = useRef<ReturnType<typeof createChart> | null>(null);
const priceChartRef = useRef<ReturnType<typeof createChart> | null>(null);
function IntervalBar({ value, onChange }: { value: string; onChange: (v: string) => void }) {
return (
<div className="flex flex-wrap gap-1.5 text-xs">
{INTERVALS.map(iv => (
<button key={iv.value} onClick={() => onChange(iv.value)}
className={`px-2.5 py-1 rounded border transition-colors ${value === iv.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-500 hover:border-slate-400"}`}>
{iv.label}
</button>
))}
</div>
);
}
const fetchAndRender = useCallback(async () => {
function KChart({
symbol, interval, mode, color,
}: {
symbol: string; interval: string; mode: "rate" | "price";
color: { up: string; down: string };
}) {
const containerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<ReturnType<typeof createChart> | null>(null);
const [count, setCount] = useState(0);
const render = useCallback(async () => {
try {
const r = await fetch(`/api/kline?symbol=${symbol}&interval=${interval}&limit=500`);
const json = await r.json();
const bars: KBar[] = json.data || [];
setCount(json.count || 0);
if (!rateRef.current || !priceRef.current) return;
// 销毁旧图表
rateChartRef.current?.remove();
priceChartRef.current?.remove();
// 费率K线
const rateChart = createChart(rateRef.current, chartOptions(260));
rateChartRef.current = rateChart;
const rateSeries = rateChart.addSeries(CandlestickSeries, {
upColor: "#16a34a",
downColor: "#dc2626",
borderUpColor: "#16a34a",
borderDownColor: "#dc2626",
wickUpColor: "#16a34a",
wickDownColor: "#dc2626",
if (!containerRef.current) return;
chartRef.current?.remove();
const chart = createChart(containerRef.current, baseChartOptions(260));
chartRef.current = chart;
const series = chart.addSeries(CandlestickSeries, {
upColor: color.up, downColor: color.down,
borderUpColor: color.up, borderDownColor: color.down,
wickUpColor: color.up, wickDownColor: color.down,
});
rateSeries.setData(bars.map(b => ({
time: b.time as any,
open: b.open,
high: b.high,
low: b.low,
close: b.close,
})));
rateChart.timeScale().fitContent();
// 隐藏 TradingView 水印
rateRef.current.querySelectorAll("a").forEach(a => a.style.display = "none");
// 价格K线
const priceChart = createChart(priceRef.current, chartOptions(260));
priceChartRef.current = priceChart;
const priceSeries = priceChart.addSeries(CandlestickSeries, {
upColor: "#2563eb",
downColor: "#7c3aed",
borderUpColor: "#2563eb",
borderDownColor: "#7c3aed",
wickUpColor: "#2563eb",
wickDownColor: "#7c3aed",
});
priceSeries.setData(bars.map(b => ({
time: b.time as any,
open: b.price_open,
high: b.price_high,
low: b.price_low,
close: b.price_close,
})));
priceChart.timeScale().fitContent();
// 隐藏 TradingView 水印
priceRef.current.querySelectorAll("a").forEach(a => a.style.display = "none");
} catch (e) {
console.error(e);
} finally {
setLoading(false);
if (mode === "rate") {
series.setData(bars.map(b => ({ time: b.time as any, open: b.open, high: b.high, low: b.low, close: b.close })));
} else {
series.setData(bars.map(b => ({ time: b.time as any, open: b.price_open, high: b.price_high, low: b.price_low, close: b.price_close })));
}
}, [symbol, interval]);
chart.timeScale().fitContent();
containerRef.current.querySelectorAll("a").forEach(a => (a as HTMLElement).style.display = "none");
} catch (e) { console.error(e); }
}, [symbol, interval, mode, color.up, color.down]);
useEffect(() => {
setLoading(true);
fetchAndRender();
const iv = window.setInterval(fetchAndRender, 30_000);
render();
const iv = window.setInterval(render, 30_000);
return () => {
window.clearInterval(iv);
rateChartRef.current?.remove();
priceChartRef.current?.remove();
rateChartRef.current = null;
priceChartRef.current = null;
chartRef.current?.remove();
chartRef.current = null;
};
}, [fetchAndRender]);
}, [render]);
return (
<div>
<p className="text-xs text-slate-400 mb-1">{count} K线</p>
<div ref={containerRef} className="w-full" style={{ height: 260 }} />
</div>
);
}
export default function KlinePage() {
const [symbol, setSymbol] = useState<"BTC" | "ETH">("BTC");
const [rateInterval, setRateInterval] = useState("1h");
const [priceInterval, setPriceInterval] = useState("1h");
return (
<div className="space-y-5">
{/* 标题+控制栏 */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
{/* 标题+币种 */}
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-slate-900"> K 线</h1>
<p className="text-slate-500 text-sm mt-1">
2 · <span className="text-blue-600 font-medium">{count.toLocaleString()}</span> K线
</p>
</div>
<div className="flex flex-wrap gap-2 text-sm">
{/* 币种 */}
<div className="flex gap-2 text-sm">
{(["BTC", "ETH"] as const).map(s => (
<button key={s} onClick={() => setSymbol(s)}
className={`px-3 py-1.5 rounded-lg border font-medium transition-colors ${symbol === s ? "bg-blue-600 text-white border-blue-600" : "border-slate-200 text-slate-600 hover:border-blue-400"}`}>
className={`px-4 py-1.5 rounded-lg border font-medium transition-colors ${symbol === s ? "bg-blue-600 text-white border-blue-600" : "border-slate-200 text-slate-600 hover:border-blue-400"}`}>
{s}
</button>
))}
<span className="w-px bg-slate-200 mx-1" />
{/* 周期 */}
{INTERVALS.map(iv => (
<button key={iv.value} onClick={() => setInterval(iv.value)}
className={`px-3 py-1.5 rounded-lg border transition-colors ${interval === iv.value ? "bg-slate-800 text-white border-slate-800" : "border-slate-200 text-slate-600 hover:border-slate-400"}`}>
{iv.label}
</button>
))}
</div>
</div>
{loading && <div className="text-slate-400 py-8 text-center">...</div>}
{/* 费率K线 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-slate-700 font-semibold text-sm">{symbol} K 线</h2>
<span className="text-xs text-slate-400">×10000 · 绿 · </span>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4 space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-slate-700 font-semibold text-sm">{symbol} </h2>
<p className="text-xs text-slate-400">×10000 · 绿 · </p>
</div>
<div ref={rateRef} className="w-full" style={{ height: 260 }} />
<IntervalBar value={rateInterval} onChange={setRateInterval} />
</div>
<KChart symbol={symbol} interval={rateInterval} mode="rate" color={{ up: "#16a34a", down: "#dc2626" }} />
</div>
{/* 价格K线 */}
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4">
<div className="flex items-center justify-between mb-2">
<h2 className="text-slate-700 font-semibold text-sm">{symbol} K 线USD</h2>
<span className="text-xs text-slate-400"> · </span>
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4 space-y-3">
<div className="flex items-center justify-between flex-wrap gap-2">
<div>
<h2 className="text-slate-700 font-semibold text-sm">{symbol} USD</h2>
<p className="text-xs text-slate-400"> · </p>
</div>
<div ref={priceRef} className="w-full" style={{ height: 260 }} />
<IntervalBar value={priceInterval} onChange={setPriceInterval} />
</div>
<KChart symbol={symbol} interval={priceInterval} mode="price" color={{ up: "#2563eb", down: "#7c3aed" }} />
</div>
<div className="rounded-lg border border-blue-100 bg-blue-50 px-5 py-3 text-sm text-slate-600">
<span className="text-blue-600 font-medium"></span>
rate_snapshots表2K线30
rate_snapshots表230
</div>
</div>
);