From 24d9044d9d2a6b26ecaaba45896d92c76bf3624e Mon Sep 17 00:00:00 2001 From: root Date: Fri, 27 Feb 2026 09:44:49 +0000 Subject: [PATCH] feat: sidebar layout + unified dashboard with kline, history, signals --- frontend/app/layout.tsx | 12 +- frontend/app/page.tsx | 318 +++++++++++++++++++++++--------- frontend/components/Sidebar.tsx | 115 ++++++++++++ frontend/lib/api.ts | 12 ++ 4 files changed, 361 insertions(+), 96 deletions(-) create mode 100644 frontend/components/Sidebar.tsx diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index b96c217..64fdfc6 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -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 ( - - -
{children}
+ +
+ +
+ {children} +
+
); diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 0061314..77b9c94 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -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(null); + const chartRef = useRef | 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
; +} + +function IntervalPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { + return ( +
+ {INTERVALS.map(iv => ( + + ))} +
+ ); +} + +// ─── 主仪表盘 ──────────────────────────────────────────────────── export default function Dashboard() { const [rates, setRates] = useState(null); const [stats, setStats] = useState(null); const [history, setHistory] = useState(null); - const [status, setStatus] = useState<"loading" | "running" | "error">("loading"); - const [lastUpdate, setLastUpdate] = useState(""); + const [signals, setSignals] = useState([]); + 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 ( -
- {/* Header */} -
+
+ {/* 标题 */} +
-

资金费率套利监控

-

实时监控 BTC / ETH 永续合约资金费率

+

资金费率套利监控

+

实时监控 BTC / ETH 永续合约资金费率

- - - {status === "running" ? "运行中" : status === "error" ? "连接失败" : "加载中..."} + + + {status==="running"?"运行中":status==="error"?"连接失败":"加载中..."} - {lastUpdate && 更新于 {lastUpdate}} + {lastUpdate && 更新于 {lastUpdate}}
- {/* Rate Cards */} + {/* 费率卡片 */}
- {/* Stats Cards */} + {/* 统计卡片 */} {stats && ( -
+
)} - {/* 历史费率折线图 */} - {allTimes.length > 0 && ( -
-

历史费率走势(过去7天,每8小时结算)

- - - - `${v.toFixed(3)}%`} width={60} /> - [`${Number(v).toFixed(4)}%`]} contentStyle={{ background: "#fff", border: "1px solid #e2e8f0", borderRadius: 8, fontSize: 12 }} /> - + {/* K线 */} +
+
+

K 线图

+
+ {(["BTC","ETH"] as const).map(s => ( + + ))} +
+
+ + {/* 费率K */} +
+
+ {symbol} 资金费率(万分之) + +
+ +
+ + {/* 价格K */} +
+
+ {symbol} 标记价格(USD) + +
+ +
+
+ + {/* 历史费率走势 */} + {historyChart.length > 0 && ( +
+

历史费率走势(过去7天)

+ + + + `${v.toFixed(3)}%`} width={58} /> + [`${Number(v).toFixed(4)}%`]} contentStyle={{ background:"#fff", border:"1px solid #e2e8f0", borderRadius:8, fontSize:12 }} /> + @@ -111,43 +212,76 @@ export default function Dashboard() {
)} - {/* 历史费率明细表 */} - {tableData.length > 0 && ( -
-
-

历史费率明细(最近50条)

+ {/* 历史明细 + 信号历史 并排 */} +
+ {/* 历史明细 */} + {tableRows.length > 0 && ( +
+
+

历史费率明细(最近30条)

+
+
+ + + + + + + + + + {tableRows.map((row, i) => ( + + + + + + ))} + +
时间BTCETH
{row.time}=0?"text-emerald-600":"text-red-500"}`}> + {row.btc!=null?`${row.btc.toFixed(4)}%`:"--"} + =0?"text-emerald-600":"text-red-500"}`}> + {row.eth!=null?`${row.eth.toFixed(4)}%`:"--"} +
+
- - - - - - - - - - {tableData.map((row, i) => ( - - - - - - ))} - -
时间BTC 费率ETH 费率
{row.time}= 0 ? "text-emerald-600" : "text-red-500"}`}> - {row.btc != null ? `${row.btc.toFixed(4)}%` : "--"} - = 0 ? "text-emerald-600" : "text-red-500"}`}> - {row.eth != null ? `${row.eth.toFixed(4)}%` : "--"} -
-
- )} + )} - {/* Strategy note */} -
+ {/* 信号历史 */} +
+
+

信号历史

+
+
+ + + + + + + + + + {signals.length === 0 ? ( + + ) : signals.map(row => ( + + + + + + ))} + +
时间币种年化
暂无信号(年化>10%时自动触发)
{new Date(row.sent_at).toLocaleString("zh-CN")}{row.symbol}{row.annualized}%
+
+
+
+ + {/* 策略说明 */} +
策略原理: 持有现货多头 + 永续空头,每8小时收取资金费率,赚取无方向风险的稳定收益。
); } - diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx new file mode 100644 index 0000000..3f96838 --- /dev/null +++ b/frontend/components/Sidebar.tsx @@ -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 }) => ( +
+ {/* Logo */} +
+ + {(!collapsed || mobile) && ( + Arbitrage
Engine
+ )} +
+ + {/* Nav */} + + + {/* Auth */} +
+ {authItems.map(({ href, label, icon: Icon }) => ( + 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" : ""}`}> + + {(!collapsed || mobile) && {label}} + + ))} +
+ + {/* Collapse toggle (desktop only) */} + {!mobile && ( + + )} +
+ ); + + return ( + <> + {/* Desktop sidebar */} + + + {/* Mobile top bar */} +
+
+ + Arbitrage Engine +
+ +
+ + {/* Mobile drawer */} + {mobileOpen && ( +
setMobileOpen(false)}> +
+ +
+ )} + + ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 9b8db43..7260a96 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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(path: string): Promise { 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("/api/signals/history"), snapshots: (hours = 24, limit = 5000) => fetchAPI(`/api/snapshots?hours=${hours}&limit=${limit}`), + kline: (symbol = "BTC", interval = "1h", limit = 500) => + fetchAPI(`/api/kline?symbol=${symbol}&interval=${interval}&limit=${limit}`), };