feat: sidebar layout + unified dashboard with kline, history, signals
This commit is contained in:
parent
88543efe5c
commit
24d9044d9d
@ -1,7 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import Sidebar from "@/components/Sidebar";
|
||||
|
||||
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||
@ -14,9 +14,13 @@ export const metadata: Metadata = {
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-white text-slate-900`}>
|
||||
<Navbar />
|
||||
<main className="max-w-6xl mx-auto px-4 py-6">{children}</main>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-screen bg-slate-50 text-slate-900`}>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="flex-1 md:p-6 p-4 pt-16 md:pt-6 min-w-0">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { api, RatesResponse, StatsResponse, HistoryResponse, HistoryPoint } from "@/lib/api";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { createChart, ColorType, CandlestickSeries } from "lightweight-charts";
|
||||
import { api, RatesResponse, StatsResponse, HistoryResponse, HistoryPoint, SignalHistoryItem, KBar } from "@/lib/api";
|
||||
import RateCard from "@/components/RateCard";
|
||||
import StatsCard from "@/components/StatsCard";
|
||||
import {
|
||||
@ -9,100 +10,200 @@ import {
|
||||
ResponsiveContainer, ReferenceLine
|
||||
} from "recharts";
|
||||
|
||||
// ─── K线子组件 ──────────────────────────────────────────────────
|
||||
const INTERVALS = [
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
function bjtTimeFormatter(ts: number) {
|
||||
const d = new Date((ts + 8 * 3600) * 1000);
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth()+1).padStart(2,"0")}-${String(d.getUTCDate()).padStart(2,"0")} ${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}`;
|
||||
}
|
||||
|
||||
function baseChartOpts(height: number) {
|
||||
return {
|
||||
layout: { background: { type: ColorType.Solid, color: "#ffffff" }, textColor: "#64748b", fontSize: 11 },
|
||||
grid: { vertLines: { color: "#f1f5f9" }, horzLines: { color: "#f1f5f9" } },
|
||||
localization: { timeFormatter: bjtTimeFormatter },
|
||||
timeScale: {
|
||||
borderColor: "#e2e8f0", timeVisible: true, secondsVisible: false,
|
||||
tickMarkFormatter: (ts: number) => {
|
||||
const d = new Date((ts + 8 * 3600) * 1000);
|
||||
return `${String(d.getUTCHours()).padStart(2,"0")}:${String(d.getUTCMinutes()).padStart(2,"0")}`;
|
||||
},
|
||||
},
|
||||
rightPriceScale: { borderColor: "#e2e8f0" },
|
||||
height,
|
||||
};
|
||||
}
|
||||
|
||||
function MiniKChart({ symbol, interval, mode, colors }: {
|
||||
symbol: string; interval: string; mode: "rate"|"price";
|
||||
colors: { up: string; down: string };
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const chartRef = useRef<ReturnType<typeof createChart> | null>(null);
|
||||
|
||||
const render = useCallback(async () => {
|
||||
try {
|
||||
const json = await api.kline(symbol, interval);
|
||||
const bars: KBar[] = json.data || [];
|
||||
if (!ref.current) return;
|
||||
chartRef.current?.remove();
|
||||
const chart = createChart(ref.current, baseChartOpts(220));
|
||||
chartRef.current = chart;
|
||||
const series = chart.addSeries(CandlestickSeries, {
|
||||
upColor: colors.up, downColor: colors.down,
|
||||
borderUpColor: colors.up, borderDownColor: colors.down,
|
||||
wickUpColor: colors.up, wickDownColor: colors.down,
|
||||
});
|
||||
series.setData(mode === "rate"
|
||||
? bars.map(b => ({ time: b.time as any, open: b.open, high: b.high, low: b.low, close: b.close }))
|
||||
: bars.map(b => ({ time: b.time as any, open: b.price_open, high: b.price_high, low: b.price_low, close: b.price_close }))
|
||||
);
|
||||
chart.timeScale().fitContent();
|
||||
ref.current.querySelectorAll("a").forEach(a => (a as HTMLElement).style.display = "none");
|
||||
} catch {}
|
||||
}, [symbol, interval, mode, colors.up, colors.down]);
|
||||
|
||||
useEffect(() => {
|
||||
render();
|
||||
const iv = window.setInterval(render, 30_000);
|
||||
return () => { window.clearInterval(iv); chartRef.current?.remove(); chartRef.current = null; };
|
||||
}, [render]);
|
||||
|
||||
return <div ref={ref} className="w-full" style={{ height: 220 }} />;
|
||||
}
|
||||
|
||||
function IntervalPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1 text-xs">
|
||||
{INTERVALS.map(iv => (
|
||||
<button key={iv.value} onClick={() => onChange(iv.value)}
|
||||
className={`px-2 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 主仪表盘 ────────────────────────────────────────────────────
|
||||
export default function Dashboard() {
|
||||
const [rates, setRates] = useState<RatesResponse | null>(null);
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||
const [history, setHistory] = useState<HistoryResponse | null>(null);
|
||||
const [status, setStatus] = useState<"loading" | "running" | "error">("loading");
|
||||
const [lastUpdate, setLastUpdate] = useState<string>("");
|
||||
const [signals, setSignals] = useState<SignalHistoryItem[]>([]);
|
||||
const [status, setStatus] = useState<"loading"|"running"|"error">("loading");
|
||||
const [lastUpdate, setLastUpdate] = useState("");
|
||||
const [symbol, setSymbol] = useState<"BTC"|"ETH">("BTC");
|
||||
const [rateInterval, setRateInterval] = useState("1h");
|
||||
const [priceInterval, setPriceInterval] = useState("1h");
|
||||
|
||||
const fetchRates = useCallback(async () => {
|
||||
try {
|
||||
const r = await api.rates();
|
||||
setRates(r);
|
||||
setStatus("running");
|
||||
setLastUpdate(new Date().toLocaleTimeString("zh-CN"));
|
||||
} catch {
|
||||
setStatus("error");
|
||||
}
|
||||
try { setRates(await api.rates()); setStatus("running"); setLastUpdate(new Date().toLocaleTimeString("zh-CN")); }
|
||||
catch { setStatus("error"); }
|
||||
}, []);
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
const fetchSlow = useCallback(async () => {
|
||||
try {
|
||||
const [s, h] = await Promise.all([api.stats(), api.history()]);
|
||||
setStats(s);
|
||||
setHistory(h);
|
||||
const [s, h, sig] = await Promise.all([api.stats(), api.history(), api.signalsHistory()]);
|
||||
setStats(s); setHistory(h); setSignals(sig.items || []);
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchRates();
|
||||
fetchAll();
|
||||
const rateInterval = setInterval(fetchRates, 2_000);
|
||||
const slowInterval = setInterval(fetchAll, 120_000);
|
||||
return () => { clearInterval(rateInterval); clearInterval(slowInterval); };
|
||||
}, [fetchRates, fetchAll]);
|
||||
fetchRates(); fetchSlow();
|
||||
const r = setInterval(fetchRates, 2_000);
|
||||
const s = setInterval(fetchSlow, 120_000);
|
||||
return () => { clearInterval(r); clearInterval(s); };
|
||||
}, [fetchRates, fetchSlow]);
|
||||
|
||||
// 历史费率表格数据
|
||||
const btcMap = new Map((history?.BTC ?? []).map((p: HistoryPoint) => [p.timestamp.slice(0, 13), p.fundingRate * 100]));
|
||||
const ethMap = new Map((history?.ETH ?? []).map((p: HistoryPoint) => [p.timestamp.slice(0, 13), p.fundingRate * 100]));
|
||||
// 历史图数据
|
||||
const btcMap = new Map((history?.BTC ?? []).map((p: HistoryPoint) => [p.timestamp.slice(0,13), p.fundingRate * 100]));
|
||||
const ethMap = new Map((history?.ETH ?? []).map((p: HistoryPoint) => [p.timestamp.slice(0,13), p.fundingRate * 100]));
|
||||
const allTimes = Array.from(new Set([...btcMap.keys(), ...ethMap.keys()])).sort();
|
||||
const historyChartData = allTimes.slice(-42).map((t) => ({
|
||||
time: t.slice(5).replace("T", " "),
|
||||
BTC: btcMap.get(t) ?? null,
|
||||
ETH: ethMap.get(t) ?? null,
|
||||
}));
|
||||
const tableData = allTimes.slice().reverse().slice(0, 50).map((t) => ({
|
||||
time: t.replace("T", " "),
|
||||
btc: btcMap.get(t),
|
||||
eth: ethMap.get(t),
|
||||
}));
|
||||
const historyChart = allTimes.slice(-42).map(t => ({ time: t.slice(5).replace("T"," "), BTC: btcMap.get(t) ?? null, ETH: ethMap.get(t) ?? null }));
|
||||
const tableRows = allTimes.slice().reverse().slice(0,30).map(t => ({ time: t.replace("T"," "), btc: btcMap.get(t), eth: ethMap.get(t) }));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-5">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">资金费率套利监控</h1>
|
||||
<p className="text-slate-500 text-sm mt-1">实时监控 BTC / ETH 永续合约资金费率</p>
|
||||
<h1 className="text-xl font-bold text-slate-900">资金费率套利监控</h1>
|
||||
<p className="text-slate-500 text-xs mt-0.5">实时监控 BTC / ETH 永续合约资金费率</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
status === "running" ? "bg-emerald-500 animate-pulse"
|
||||
: status === "error" ? "bg-red-500" : "bg-slate-300"
|
||||
}`} />
|
||||
<span className={status === "running" ? "text-emerald-600" : status === "error" ? "text-red-600" : "text-slate-400"}>
|
||||
{status === "running" ? "运行中" : status === "error" ? "连接失败" : "加载中..."}
|
||||
<span className={`w-2 h-2 rounded-full ${status==="running"?"bg-emerald-500 animate-pulse":status==="error"?"bg-red-500":"bg-slate-300"}`} />
|
||||
<span className={status==="running"?"text-emerald-600":status==="error"?"text-red-600":"text-slate-400"}>
|
||||
{status==="running"?"运行中":status==="error"?"连接失败":"加载中..."}
|
||||
</span>
|
||||
{lastUpdate && <span className="text-slate-400 ml-2">更新于 {lastUpdate}</span>}
|
||||
{lastUpdate && <span className="text-slate-400 text-xs">更新于 {lastUpdate}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rate Cards */}
|
||||
{/* 费率卡片 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<RateCard asset="BTC" data={rates?.BTC ?? null} />
|
||||
<RateCard asset="ETH" data={rates?.ETH ?? null} />
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{/* 统计卡片 */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<StatsCard title="BTC 套利" mean7d={stats.BTC.mean7d} annualized={stats.BTC.annualized} accent="blue" />
|
||||
<StatsCard title="ETH 套利" mean7d={stats.ETH.mean7d} annualized={stats.ETH.annualized} accent="indigo" />
|
||||
<StatsCard title="50/50 组合" mean7d={stats.combo.mean7d} annualized={stats.combo.annualized} accent="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史费率折线图 */}
|
||||
{allTimes.length > 0 && (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-6">
|
||||
<h2 className="text-slate-800 font-semibold mb-4">历史费率走势(过去7天,每8小时结算)</h2>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<LineChart data={historyChartData} margin={{ top: 4, right: 8, bottom: 4, left: 8 }}>
|
||||
<XAxis dataKey="time" tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill: "#94a3b8", fontSize: 10 }} tickLine={false} axisLine={false} tickFormatter={(v) => `${v.toFixed(3)}%`} width={60} />
|
||||
<Tooltip formatter={(v) => [`${Number(v).toFixed(4)}%`]} contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 12 }} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
{/* K线 */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4 space-y-4">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<h2 className="font-semibold text-slate-800 text-sm">K 线图</h2>
|
||||
<div className="flex gap-1.5">
|
||||
{(["BTC","ETH"] as const).map(s => (
|
||||
<button key={s} onClick={() => setSymbol(s)}
|
||||
className={`px-3 py-1 rounded-lg border text-xs 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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 费率K */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<span className="text-xs text-slate-500 font-medium">{symbol} 资金费率(万分之)</span>
|
||||
<IntervalPicker value={rateInterval} onChange={setRateInterval} />
|
||||
</div>
|
||||
<MiniKChart symbol={symbol} interval={rateInterval} mode="rate" colors={{ up:"#16a34a", down:"#dc2626" }} />
|
||||
</div>
|
||||
|
||||
{/* 价格K */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2 flex-wrap gap-2">
|
||||
<span className="text-xs text-slate-500 font-medium">{symbol} 标记价格(USD)</span>
|
||||
<IntervalPicker value={priceInterval} onChange={setPriceInterval} />
|
||||
</div>
|
||||
<MiniKChart symbol={symbol} interval={priceInterval} mode="price" colors={{ up:"#2563eb", down:"#7c3aed" }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 历史费率走势 */}
|
||||
{historyChart.length > 0 && (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm p-4">
|
||||
<h2 className="font-semibold text-slate-800 text-sm mb-3">历史费率走势(过去7天)</h2>
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={historyChart} margin={{ top:4, right:8, bottom:4, left:8 }}>
|
||||
<XAxis dataKey="time" tick={{ fill:"#94a3b8", fontSize:10 }} tickLine={false} interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fill:"#94a3b8", fontSize:10 }} tickLine={false} axisLine={false} tickFormatter={v=>`${v.toFixed(3)}%`} width={58} />
|
||||
<Tooltip formatter={v=>[`${Number(v).toFixed(4)}%`]} contentStyle={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:8, fontSize:12 }} />
|
||||
<Legend wrapperStyle={{ fontSize:12 }} />
|
||||
<ReferenceLine y={0} stroke="#94a3b8" strokeDasharray="4 2" />
|
||||
<Line type="monotone" dataKey="BTC" stroke="#2563eb" strokeWidth={1.5} dot={false} connectNulls />
|
||||
<Line type="monotone" dataKey="ETH" stroke="#7c3aed" strokeWidth={1.5} dot={false} connectNulls />
|
||||
@ -111,43 +212,76 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 历史费率明细表 */}
|
||||
{tableData.length > 0 && (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-6 py-4 border-b border-slate-100">
|
||||
<h2 className="text-slate-800 font-semibold">历史费率明细(最近50条)</h2>
|
||||
{/* 历史明细 + 信号历史 并排 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* 历史明细 */}
|
||||
{tableRows.length > 0 && (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800 text-sm">历史费率明细(最近30条)</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-100">
|
||||
<th className="text-left px-4 py-2 text-slate-500 font-medium">时间</th>
|
||||
<th className="text-right px-4 py-2 text-blue-600 font-medium">BTC</th>
|
||||
<th className="text-right px-4 py-2 text-violet-600 font-medium">ETH</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableRows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-slate-50 hover:bg-slate-50">
|
||||
<td className="px-4 py-1.5 text-slate-400 font-mono">{row.time}</td>
|
||||
<td className={`px-4 py-1.5 text-right font-mono ${row.btc==null?"text-slate-300":row.btc>=0?"text-emerald-600":"text-red-500"}`}>
|
||||
{row.btc!=null?`${row.btc.toFixed(4)}%`:"--"}
|
||||
</td>
|
||||
<td className={`px-4 py-1.5 text-right font-mono ${row.eth==null?"text-slate-300":row.eth>=0?"text-emerald-600":"text-red-500"}`}>
|
||||
{row.eth!=null?`${row.eth.toFixed(4)}%`:"--"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 bg-slate-50">
|
||||
<th className="text-left px-4 py-3 text-slate-500 font-medium">时间</th>
|
||||
<th className="text-right px-4 py-3 text-blue-600 font-medium">BTC 费率</th>
|
||||
<th className="text-right px-4 py-3 text-violet-600 font-medium">ETH 费率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tableData.map((row, i) => (
|
||||
<tr key={i} className="border-b border-slate-100 hover:bg-slate-50">
|
||||
<td className="px-4 py-2 text-slate-500 font-mono text-xs">{row.time}</td>
|
||||
<td className={`px-4 py-2 text-right font-mono text-xs ${row.btc == null ? "text-slate-400" : row.btc >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{row.btc != null ? `${row.btc.toFixed(4)}%` : "--"}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-right font-mono text-xs ${row.eth == null ? "text-slate-400" : row.eth >= 0 ? "text-emerald-600" : "text-red-500"}`}>
|
||||
{row.eth != null ? `${row.eth.toFixed(4)}%` : "--"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Strategy note */}
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50 px-5 py-3 text-sm text-slate-600">
|
||||
{/* 信号历史 */}
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-4 py-3 border-b border-slate-100">
|
||||
<h2 className="font-semibold text-slate-800 text-sm">信号历史</h2>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-100">
|
||||
<th className="text-left px-4 py-2 text-slate-500 font-medium">时间</th>
|
||||
<th className="text-left px-4 py-2 text-slate-500 font-medium">币种</th>
|
||||
<th className="text-right px-4 py-2 text-slate-500 font-medium">年化</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{signals.length === 0 ? (
|
||||
<tr><td colSpan={3} className="px-4 py-6 text-center text-slate-400">暂无信号(年化>10%时自动触发)</td></tr>
|
||||
) : signals.map(row => (
|
||||
<tr key={row.id} className="border-b border-slate-50 hover:bg-slate-50">
|
||||
<td className="px-4 py-1.5 text-slate-400 font-mono">{new Date(row.sent_at).toLocaleString("zh-CN")}</td>
|
||||
<td className="px-4 py-1.5 font-medium text-slate-700">{row.symbol}</td>
|
||||
<td className="px-4 py-1.5 text-right font-mono text-blue-600">{row.annualized}%</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 策略说明 */}
|
||||
<div className="rounded-lg border border-blue-100 bg-blue-50 px-4 py-3 text-xs text-slate-600">
|
||||
<span className="text-blue-600 font-medium">策略原理:</span>
|
||||
持有现货多头 + 永续空头,每8小时收取资金费率,赚取无方向风险的稳定收益。
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
115
frontend/components/Sidebar.tsx
Normal file
115
frontend/components/Sidebar.tsx
Normal file
@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
LayoutDashboard, TrendingUp, Bell, Info, LogIn, UserPlus,
|
||||
ChevronLeft, ChevronRight, Menu, X, Zap
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/", label: "仪表盘", icon: LayoutDashboard },
|
||||
{ href: "/kline", label: "K线大图", icon: TrendingUp },
|
||||
{ href: "/signals", label: "信号历史", icon: Bell },
|
||||
{ href: "/about", label: "说明", icon: Info },
|
||||
];
|
||||
|
||||
const authItems = [
|
||||
{ href: "/login", label: "登录", icon: LogIn },
|
||||
{ href: "/register", label: "注册", icon: UserPlus },
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
const SidebarContent = ({ mobile = false }: { mobile?: boolean }) => (
|
||||
<div className={`flex flex-col h-full ${mobile ? "w-64" : collapsed ? "w-16" : "w-60"} transition-all duration-200`}>
|
||||
{/* Logo */}
|
||||
<div className={`flex items-center gap-2 px-4 py-5 border-b border-slate-100 ${collapsed && !mobile ? "justify-center px-0" : ""}`}>
|
||||
<Zap className="w-6 h-6 text-blue-600 shrink-0" />
|
||||
{(!collapsed || mobile) && (
|
||||
<span className="font-bold text-slate-800 text-base leading-tight">Arbitrage<br/>Engine</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-4 space-y-1 px-2">
|
||||
{navItems.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname === href;
|
||||
return (
|
||||
<Link key={href} href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm transition-colors
|
||||
${active ? "bg-blue-50 text-blue-700 font-medium" : "text-slate-600 hover:bg-slate-100 hover:text-slate-900"}
|
||||
${collapsed && !mobile ? "justify-center px-0" : ""}`}>
|
||||
<Icon className={`shrink-0 ${active ? "text-blue-600" : "text-slate-400"}`} size={18} />
|
||||
{(!collapsed || mobile) && <span>{label}</span>}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* Auth */}
|
||||
<div className="border-t border-slate-100 py-3 px-2 space-y-1">
|
||||
{authItems.map(({ href, label, icon: Icon }) => (
|
||||
<Link key={href} href={href}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-slate-500 hover:bg-slate-100 hover:text-slate-800 transition-colors
|
||||
${collapsed && !mobile ? "justify-center px-0" : ""}`}>
|
||||
<Icon className="shrink-0 text-slate-400" size={16} />
|
||||
{(!collapsed || mobile) && <span>{label}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle (desktop only) */}
|
||||
{!mobile && (
|
||||
<button onClick={() => setCollapsed(!collapsed)}
|
||||
className="flex items-center justify-center p-3 border-t border-slate-100 text-slate-400 hover:text-slate-600 hover:bg-slate-50 transition-colors">
|
||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop sidebar */}
|
||||
<aside className={`hidden md:flex flex-col bg-white border-r border-slate-200 min-h-screen sticky top-0 shrink-0 transition-all duration-200 ${collapsed ? "w-16" : "w-60"}`}>
|
||||
<SidebarContent />
|
||||
</aside>
|
||||
|
||||
{/* Mobile top bar */}
|
||||
<div className="md:hidden fixed top-0 left-0 right-0 z-40 bg-white border-b border-slate-200 flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-bold text-slate-800 text-sm">Arbitrage Engine</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(!mobileOpen)} className="text-slate-600 p-1">
|
||||
{mobileOpen ? <X size={22} /> : <Menu size={22} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile drawer */}
|
||||
{mobileOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-30" onClick={() => setMobileOpen(false)}>
|
||||
<div className="absolute inset-0 bg-black/30" />
|
||||
<aside className="absolute top-0 left-0 bottom-0 bg-white border-r border-slate-200 z-40 flex flex-col"
|
||||
onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-100">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-blue-600" />
|
||||
<span className="font-bold text-slate-800 text-sm">Arbitrage Engine</span>
|
||||
</div>
|
||||
<button onClick={() => setMobileOpen(false)} className="text-slate-400 p-1"><X size={18} /></button>
|
||||
</div>
|
||||
<SidebarContent mobile />
|
||||
</aside>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -64,6 +64,16 @@ export interface SnapshotsResponse {
|
||||
data: SnapshotItem[];
|
||||
}
|
||||
|
||||
export interface KBar {
|
||||
time: number;
|
||||
open: number; high: number; low: number; close: number;
|
||||
price_open: number; price_high: number; price_low: number; price_close: number;
|
||||
}
|
||||
|
||||
export interface KlineResponse {
|
||||
symbol: string; interval: string; count: number; data: KBar[];
|
||||
}
|
||||
|
||||
async function fetchAPI<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${path}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`API error ${res.status}`);
|
||||
@ -78,4 +88,6 @@ export const api = {
|
||||
signalsHistory: () => fetchAPI<SignalsHistoryResponse>("/api/signals/history"),
|
||||
snapshots: (hours = 24, limit = 5000) =>
|
||||
fetchAPI<SnapshotsResponse>(`/api/snapshots?hours=${hours}&limit=${limit}`),
|
||||
kline: (symbol = "BTC", interval = "1h", limit = 500) =>
|
||||
fetchAPI<KlineResponse>(`/api/kline?symbol=${symbol}&interval=${interval}&limit=${limit}`),
|
||||
};
|
||||
|
||||
Loading…
Reference in New Issue
Block a user