"use client"; import { useEffect, useRef, useState, useCallback } from "react"; import { createChart, ColorType, CandlestickSeries, LineSeries } 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" }, ]; interface KBar { time: 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) { return { layout: { background: { type: ColorType.Solid, color: "#ffffff" }, textColor: "#64748b", fontSize: 11, }, grid: { vertLines: { color: "#f1f5f9" }, horzLines: { color: "#f1f5f9" }, }, crosshair: { mode: 1 }, 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"); const D = String(d.getUTCDate()).padStart(2, "0"); const h = String(d.getUTCHours()).padStart(2, "0"); const m = String(d.getUTCMinutes()).padStart(2, "0"); return `${Y}-${M}-${D} ${h}:${m}`; }, }, timeScale: { 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}`; }, }, rightPriceScale: { borderColor: "#e2e8f0" }, height, }; } export default function KlinePage() { const rateRef = useRef(null); const priceRef = useRef(null); const [symbol, setSymbol] = useState<"BTC" | "ETH">("BTC"); const [interval, setInterval] = useState("5m"); const [count, setCount] = useState(0); const [loading, setLoading] = useState(true); const rateChartRef = useRef | null>(null); const priceChartRef = useRef | null>(null); const fetchAndRender = 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", }); 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(); // 价格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(); } catch (e) { console.error(e); } finally { setLoading(false); } }, [symbol, interval]); useEffect(() => { setLoading(true); fetchAndRender(); const iv = window.setInterval(fetchAndRender, 30_000); return () => { window.clearInterval(iv); rateChartRef.current?.remove(); priceChartRef.current?.remove(); rateChartRef.current = null; priceChartRef.current = null; }; }, [fetchAndRender]); return (
{/* 标题+控制栏 */}

费率 K 线

本地2秒粒度数据聚合 · 已记录 {count.toLocaleString()} 根K线

{/* 币种 */} {(["BTC", "ETH"] as const).map(s => ( ))} {/* 周期 */} {INTERVALS.map(iv => ( ))}
{loading &&
加载中...
} {/* 费率K线 */}

{symbol} 资金费率 K 线(%)

绿涨红跌 · 代表多头情绪强弱
{/* 价格K线 */}

{symbol} 标记价格 K 线(USD)

蓝涨紫跌 · 与费率对比观察趋势
说明: 数据来自本地rate_snapshots表(每2秒一条),在前端聚合为各时间周期K线,每30秒自动刷新。数据仅从露露服务器启动后开始积累,历史越长越有价值。
); }